From ac30b0a6ddfb29d5599b69032b56d34f68237c59 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 13 Aug 2026 13:42:39 +0100 Subject: [PATCH 1/3] Preserve legacy Mineralogy upgrades on Forge 26.1.2 --- CHANGELOG.txt | 4 + README.md | 10 + docs/CONFIGURATION.md | 14 + docs/MIGRATION.md | 31 + docs/PLAYER_GUIDE.md | 17 + .../moddev/mc/orespawn/worldgen/Geology.java | 69 +- .../worldgen/LegacyConfigMigrator.java | 39 ++ .../LegacyMineralogyProfileMigration.java | 629 ++++++++++++++++++ .../mc/orespawn/worldgen/StoneReplacer.java | 7 +- .../worldgen/WorldGeologyProfile.java | 31 + .../worldgen/WorldGeologyProfileManager.java | 18 +- .../worldgen/LegacyConfigMigratorTest.java | 3 + .../LegacyMineralogyProfileMigrationTest.java | 356 ++++++++++ .../worldgen/WorldGeologyProfileTest.java | 49 ++ 14 files changed, 1263 insertions(+), 14 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 82c88356..63041294 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,9 @@ Version 4.0.6 +* Preserve generated worlds made with Mineralogy 1.10, 1.12, or 5.x by reading their saved mod metadata and exact legacy configuration before creating the OreSpawn world profile. +* Write human-readable, idempotent upgrade reports for legacy OreSpawn and Mineralogy imports while retaining source files and existing chunks unchanged. +* Audit automated runtime logs and dynamic-fluid generation so logged worldgen failures cannot pass merely because the process exits normally. +* Qualify existing OreSpawn 4.0.5 global and per-world profiles without changing explicit Custom values or provider definitions. * Fix provider top and filler materials being generated one block below exposed ground. * Apply underwater materials from the corrected ground and ceiling materials to roof undersides. * Preserve trees, vegetation, structures and block entities by running surface replacement before late features. diff --git a/README.md b/README.md index b575a2e0..55d76efe 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Important files: | `/serverconfig/orespawn-worldgen.json` | Complete settings snapshot for one world | | `config/-orespawn.json` | Optional modpack override for one provider | | `config/orespawn-guide/README.md` | Guide exported automatically on first load | +| `config/orespawn-upgrade-report.txt` | Human summary produced when legacy OreSpawn rules are imported | +| `/serverconfig/orespawn-upgrade-report.txt` | Human summary produced when a generated legacy Mineralogy world is pinned to its saved settings | Profile edits affect newly generated chunks. Ore and flat-bedrock retrogen are separate opt-in features; OreSpawn never retro-generates rock strata. @@ -46,6 +48,14 @@ Stable Layers honours exact biome-ID geome influences on dynamic biome registries and spreads close geome transitions across layers rather than changing an entire vertical rock column at one boundary. +When an already-generated world has saved Mineralogy 1.10, 1.12, or 5.x mod +metadata but no OreSpawn world profile, OreSpawn reads the matching published +configuration contract and records the exact engine, numeric settings, rock +order, and white/blacklists in the new world profile. Saved-world identity +wins over stale files in the installation. A fresh world is never reclassified +merely because an old `mineralogy.cfg` or `mineralogy-common.toml` remains in +the instance. + To move a configured single-player world to a dedicated server, copy the world's `serverconfig/orespawn-worldgen.json` with the world and install the same provider mods on the server. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0ff794cc..d35473bf 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -17,6 +17,14 @@ packaged or API providers, provider override files, the global configuration, the selected template, and Create World edits. The result is saved with the world. Restart after editing JSON by hand. +An existing generated world follows a stricter safety order. Its existing +`serverconfig/orespawn-worldgen.json` always wins. If none exists, saved legacy +Mineralogy mod metadata may select the matching 1.10, 1.12, or 5.x config +contract before the world profile is first written. Installed-pack defaults, +Create World choices, and unrelated stale legacy files cannot override that +saved-world identity. See `MIGRATION.md` and the generated per-world upgrade +report for the exact decision. + ## Top-Level Fields | Field | Values | Meaning | @@ -94,6 +102,12 @@ Cyano settings use `cyano.geome_size` (4-32767), `cyano.rock_layer_noise` (1-32767), and `cyano.rock_layer_thickness` (1-255). They are ignored by Sky. +Profiles created from a legacy Mineralogy world also retain +`cyano.enabled`, `cyano.realistic_coal_layers`, the three effective +`*_rocks` arrays, the six original `*_whitelist`/`*_blacklist` arrays, and +source/version fields. These are migration snapshots, not new settings that a +fresh pack needs to author. + ## Rocks And Geomes A rock requires `enabled`, `family`, `depth_peak`, `depth_spread`, `min_y`, diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 631deaab..92846911 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -13,6 +13,37 @@ legacy-ID behaviour. Migration is non-destructive. OreSpawn writes `config/orespawn-worldgen.json` only when that target does not already exist and retains every source file. +When legacy OreSpawn rules are translated, a concise player-facing summary is +also written atomically to `config/orespawn-upgrade-report.txt`; the existing +detailed rule report remains at `config/orespawn-migration/migration-report.txt`. + +## Existing Mineralogy Worlds + +An already-generated world without an OreSpawn per-world profile is inspected +before OreSpawn chooses any installed-pack or Create World default. OreSpawn +uses saved mod metadata from `level.dat`, with a valid `level.dat_old` as a +fallback, to distinguish these published contracts: + +- Mineralogy 1.10.2 `3.3.8.26` and its `mineralogy.cfg`; +- Mineralogy 1.12.2 `3.8.0.53` and its distinct `mineralogy.cfg`; +- Mineralogy 5.0.1 through 5.4.0 and `mineralogy-common.toml`. + +The resulting world profile preserves enablement, selected legacy/geome +engine, geome size, layer noise and thickness, realistic-coal behavior where +supported, exact effective rock order (including historical duplicates), and +all six white/blacklists. Saved-world identity chooses the lineage even when a +different stale config is present. Missing or malformed values use that +lineage's published defaults and are reported rather than silently broadening +the world configuration. + +The human-readable result is written atomically to +`/serverconfig/orespawn-upgrade-report.txt`. It identifies the saved +version and metadata source, config source, selected engine and lineage, +effective settings and outputs, missing IDs, fallbacks, and warnings. Source +configuration and existing chunks are not rewritten. Once +`orespawn-worldgen.json` exists it is authoritative and the import is not run +again. A fresh world containing stale legacy files remains on its explicit +OreSpawn/Create World settings. When `config/mineralogy-geomes.json` exists, OreSpawn imports the Mineralogy 6 profile directly, updates its schema marker, and records `migrated_from`. diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index f18e6e76..08a587f4 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -93,3 +93,20 @@ the same mods. Alternatively, place a prepared global profile at The server console commands `/orespawn status`, `/orespawn reload`, and `/orespawn dump-biomes` help pack authors diagnose active providers and IDs. + +### Upgrading a Mineralogy world + +If the world was already generated with Mineralogy 1.10, 1.12, or 5.x and has +no OreSpawn world profile yet, OreSpawn reads the Mineralogy version saved in +the world and the matching old configuration. It preserves the selected +engine, numeric settings, rock order, and lists rather than silently applying +new-world defaults. Look for: + +```text +/serverconfig/orespawn-upgrade-report.txt +``` + +The report explains what was detected and retained, including any missing rock +IDs or fallback values. OreSpawn leaves the old configuration and generated +chunks untouched. A fresh world does not inherit this behavior merely because +an old Mineralogy config is still present in the instance. diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java index 1ff5b44c..8b11d3b8 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java @@ -1,5 +1,7 @@ package zone.moddev.mc.orespawn.worldgen; +import java.util.ArrayList; +import java.util.List; import java.util.Random; import zone.moddev.mc.orespawn.worldgen.math.PerlinNoise2D; @@ -15,8 +17,13 @@ import net.minecraft.core.Holder; import net.minecraft.resources.Identifier; import net.minecraft.world.level.biome.Biome; +import net.minecraftforge.registries.ForgeRegistries; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; public class Geology { + private static final Logger LOGGER = LogManager.getLogger(); private final PerlinNoise2D geomeNoiseLayer; private final PerlinNoise2D rockNoiseLayer; private final short[] whiteNoiseArray; @@ -24,10 +31,32 @@ public class Geology { private final BlockState[] metamorphicStones; private final BlockState[] sedimentaryStones; private final int layerThickness; + private final boolean realisticCoalLayers; public Geology(long seed, double geomeSize, double rockLayerSize, int layerThickness, BakedGeomeConfig config) { + this(seed, geomeSize, rockLayerSize, layerThickness, false, + config.statesForFamily(RockFamily.IGNEOUS_INTRUSIVE, RockFamily.IGNEOUS_VOLCANIC), + config.statesForFamily(RockFamily.METAMORPHIC), + config.statesForFamily(RockFamily.SEDIMENTARY)); + } + + Geology(long seed, WorldGeologyProfile profile, BakedGeomeConfig config) { + this(seed, profile.cyanoGeomeSize(), profile.cyanoRockLayerNoise(), + profile.cyanoLayerThickness(), profile.cyanoRealisticCoalLayers(), + resolveRockOrder(profile, "igneous_rocks", + config.statesForFamily(RockFamily.IGNEOUS_INTRUSIVE, RockFamily.IGNEOUS_VOLCANIC)), + resolveRockOrder(profile, "metamorphic_rocks", + config.statesForFamily(RockFamily.METAMORPHIC)), + resolveRockOrder(profile, "sedimentary_rocks", + config.statesForFamily(RockFamily.SEDIMENTARY))); + } + + Geology(long seed, double geomeSize, double rockLayerSize, int layerThickness, + boolean realisticCoalLayers, BlockState[] igneousStones, + BlockState[] metamorphicStones, BlockState[] sedimentaryStones) { this.layerThickness = layerThickness; + this.realisticCoalLayers = realisticCoalLayers; int rockLayerUndertones = 4; int undertoneMultiplier = 1 << (rockLayerUndertones - 1); geomeNoiseLayer = new PerlinNoise2D(~seed, 128, (float) geomeSize, 2); @@ -40,9 +69,9 @@ public Geology(long seed, double geomeSize, double rockLayerSize, int layerThick whiteNoiseArray[i] = (short) random.nextInt(0x7FFF); } - igneousStones = config.statesForFamily(RockFamily.IGNEOUS_INTRUSIVE, RockFamily.IGNEOUS_VOLCANIC); - metamorphicStones = config.statesForFamily(RockFamily.METAMORPHIC); - sedimentaryStones = config.statesForFamily(RockFamily.SEDIMENTARY); + this.igneousStones = igneousStones; + this.metamorphicStones = metamorphicStones; + this.sedimentaryStones = sedimentaryStones; } public Block getStoneAt(int x, int y, int z) { @@ -83,9 +112,11 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer for (; y >= chunk.getMinY(); y--) { cursor.set(x, y, z); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current)) { - StoneReplacer.setRockState(chunk, cursor, current, - pickReplacement(baseRockVal, geomeBase, y)); + if (terrain.isReplaceable(current) + || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) { + BlockState replacement = pickReplacement(baseRockVal, geomeBase, y); + if (current.equals(replacement)) continue; + StoneReplacer.setRockState(chunk, cursor, current, replacement); changed = true; } } @@ -133,4 +164,30 @@ private BlockState pickStateFromList(int value, BlockState[] list) { return list[whiteNoiseArray[(value / layerThickness) & 0xFF] % list.length]; } + static BlockState[] resolveRockOrder(WorldGeologyProfile profile, String key, + BlockState[] fallback) { + if (!profile.hasCyanoRockOrder(key)) return fallback; + List states = new ArrayList<>(); + for (String idText : profile.cyanoRockOrder(key)) { + try { + ResourceLocation id = ResourceLocation.parse(idText); + Block block = ForgeRegistries.BLOCKS.containsKey(id) + ? ForgeRegistries.BLOCKS.getValue(id) : null; + if (block != null && block != Blocks.AIR) { + states.add(block.getDefaultState()); + } else { + LOGGER.warn("Legacy Mineralogy rock '{}' is not registered and will be omitted", id); + } + } catch (RuntimeException e) { + LOGGER.warn("Legacy Mineralogy rock registry name '{}' is invalid and will be omitted", idText); + } + } + if (states.isEmpty()) { + LOGGER.warn("No snapshotted legacy Mineralogy rocks for '{}' are registered; " + + "using the matching provider family as a safe fallback", key); + return fallback; + } + return states.toArray(new BlockState[states.size()]); + } + } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java index e10975a1..d163b692 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -94,6 +94,7 @@ static JsonObject migrateIfNeeded(Path target, JsonObject defaults, report.add("Original files were retained unchanged. Review registry IDs and biome/dimension warnings before deleting them."); if (!write(target, migrated)) return null; writeReport(config, report); + writeUpgradeReport(config, imported, report); LOGGER.info("Migrated {} legacy OreSpawn definitions into '{}'", imported, target); return migrated; } @@ -354,6 +355,44 @@ private static void writeReport(Path config, List lines) { } } + private static void writeUpgradeReport(Path config, int imported, List detail) { + List lines = new ArrayList<>(); + lines.add("OreSpawn 4.0.6 Upgrade Report"); + lines.add("================================"); + lines.add(""); + lines.add("RESULT: Legacy OreSpawn settings were imported into the OS4 profile."); + lines.add("- Spawn definitions imported: " + imported); + lines.add("- Detailed translation report: " + + config.resolve("orespawn-migration/migration-report.txt").toAbsolutePath()); + for (String entry : detail) { + if (entry.startsWith("Warning:") || entry.startsWith("Skipped") + || entry.startsWith("Clamped")) lines.add("- " + entry); + } + lines.add(""); + lines.add("Original legacy configuration files were retained unchanged."); + writeTextAtomically(config.resolve("orespawn-upgrade-report.txt"), lines); + } + + private static void writeTextAtomically(Path path, List lines) { + Path temporary = path.resolveSibling(path.getFileName().toString() + ".tmp"); + try { + Files.createDirectories(path.getParent()); + byte[] bytes = (String.join(System.lineSeparator(), lines) + System.lineSeparator()) + .getBytes(StandardCharsets.UTF_8); + if (Files.isRegularFile(path) && Arrays.equals(Files.readAllBytes(path), bytes)) return; + Files.write(temporary, bytes); + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + try { Files.deleteIfExists(temporary); } catch (IOException ignored) { } + LOGGER.warn("Could not write OreSpawn upgrade report '{}'", path, e); + } + } + private static JsonObject object(JsonObject root, String key) { if (!root.has(key) || !root.get(key).isJsonObject()) root.add(key, new JsonObject()); return root.getAsJsonObject(key); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java new file mode 100644 index 00000000..ade51d98 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -0,0 +1,629 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.nbt.CompressedStreamTools; +import net.minecraft.nbt.CompoundNBT; +import net.minecraft.nbt.ListNBT; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.registries.ForgeRegistries; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; + +/** + * Snapshots the exact Mineralogy geology contract used by an already-generated + * world before OreSpawn becomes responsible for that world's geology. + * + *

The 1.10 and 1.12 Forge configuration files are related but not + * interchangeable. Mineralogy 5.x uses a third, TOML-based contract and can + * select either its Cyano layer engine or its geome engine. Saved world mod + * metadata therefore chooses the lineage; merely finding an old file in a + * reused instance is never enough to reclassify a fresh world.

+ */ +final class LegacyMineralogyProfileMigration { + private static final Logger LOGGER = LogManager.getLogger(); + private static final String CFG_FILE = "mineralogy.cfg"; + private static final String TOML_FILE = "mineralogy-common.toml"; + + private static final List IGNEOUS_110 = list( + "mineralogy:diabase", "mineralogy:gabbro", "mineralogy:peridotite", + "mineralogy:basaltic_glass", "mineralogy:scoria", "mineralogy:tuff", + "mineralogy:andesite", "mineralogy:basalt", "mineralogy:diorite", + "mineralogy:granite", "mineralogy:rhyolite", "mineralogy:pegmatite", + "mineralogy:pumice"); + private static final List METAMORPHIC_110 = list( + "mineralogy:hornfels", "mineralogy:quartzite", "mineralogy:novaculite", + "mineralogy:slate", "mineralogy:schist", "mineralogy:gneiss", + "mineralogy:phyllite", "mineralogy:amphibolite"); + private static final List SEDIMENTARY_110_BEFORE_COAL = list( + "mineralogy:siltstone", "mineralogy:shale", "mineralogy:conglomerate", + "mineralogy:dolomite", "mineralogy:limestone", "mineralogy:marble", + "minecraft:sandstone"); + private static final List SEDIMENTARY_110_AFTER_COAL = list( + "mineralogy:chert", "mineralogy:gypsum", "mineralogy:chalk", + "mineralogy:rock_salt"); + + private static final List IGNEOUS_112 = list( + "mineralogy:andesite", "mineralogy:basalt", "mineralogy:diorite", + "mineralogy:granite", "mineralogy:rhyolite", "mineralogy:pegmatite", + "mineralogy:diabase", "mineralogy:gabbro", "mineralogy:peridotite", + "mineralogy:basaltic_glass", "mineralogy:scoria", "mineralogy:tuff", + "mineralogy:pumice"); + private static final List METAMORPHIC_112 = list( + "mineralogy:slate", "mineralogy:schist", "mineralogy:gneiss", + "mineralogy:phyllite", "mineralogy:amphibolite", "mineralogy:hornfels", + "mineralogy:quartzite", "mineralogy:novaculite"); + private static final List SEDIMENTARY_112 = list( + "mineralogy:shale", "mineralogy:conglomerate", "mineralogy:dolomite", + "mineralogy:limestone", "mineralogy:siltstone", "mineralogy:marble", + "minecraft:sandstone", "mineralogy:chert", "mineralogy:gypsum", + "mineralogy:chalk", "mineralogy:rock_salt", "mineralogy:rock_salt"); + + /* Exact registration order used by published Mineralogy 5.0.1 through 5.4.0. */ + private static final List IGNEOUS_5 = list( + "mineralogy:andesite", "mineralogy:basalt", "mineralogy:diorite", + "mineralogy:granite", "mineralogy:rhyolite", "mineralogy:pegmatite", + "mineralogy:diabase", "mineralogy:gabbro", "mineralogy:peridotite", + "mineralogy:basaltic_glass", "mineralogy:scoria", "mineralogy:tuff", + "mineralogy:pumice"); + private static final List METAMORPHIC_5 = list( + "mineralogy:marble", "mineralogy:slate", "mineralogy:schist", + "mineralogy:gneiss", "mineralogy:phyllite", "mineralogy:amphibolite", + "mineralogy:hornfels", "mineralogy:quartzite", "mineralogy:novaculite"); + private static final List SEDIMENTARY_5 = list( + "mineralogy:shale", "mineralogy:conglomerate", "mineralogy:dolomite", + "mineralogy:limestone", "mineralogy:siltstone", "mineralogy:rock_salt", + "minecraft:sandstone", "mineralogy:chert", "mineralogy:gypsum", + "mineralogy:chalk"); + + private LegacyMineralogyProfileMigration() { + } + + static WorldGeologyProfile migrateIfNeeded(Path worldRoot, Path configDirectory, + WorldGeologyProfile installedPackProfile) { + // A per-world OreSpawn profile is authoritative. Keep this guard here as + // well as in the server lifecycle caller so future call sites cannot + // accidentally reclassify an established OS4 world from stale files. + if (Files.isRegularFile(worldRoot.resolve("serverconfig") + .resolve("orespawn-worldgen.json"))) return null; + if (!hasGeneratedOverworldChunks(worldRoot)) return null; + + MineralogyIdentity identity = legacyMineralogyIdentity(worldRoot); + if (identity == null) return null; + + Lineage lineage = Lineage.forVersion(identity.version); + Path configPath = configDirectory.resolve(lineage == Lineage.MINERALOGY_5 + ? TOML_FILE : CFG_FILE); + ConfigValues values = lineage == Lineage.MINERALOGY_5 + ? readToml(configPath) : readForgeCfg(configPath); + boolean configFound = Files.isRegularFile(configPath); + List warnings = new ArrayList<>(); + if (!configFound) { + Path other = configDirectory.resolve(lineage == Lineage.MINERALOGY_5 + ? CFG_FILE : TOML_FILE); + if (Files.isRegularFile(other)) { + warnings.add("Found " + other.getFileName() + " but saved world metadata selects " + + lineage.label + "; published " + lineage.label + " defaults were used."); + } + } + + boolean hybridConfig = values.scalars.containsKey("place_mineralogy_rock") + && values.scalars.containsKey("realistic_coal_layers"); + boolean enabled = lineage == Lineage.MINERALOGY_110 + ? true : bool(values, "place_mineralogy_rock", true); + boolean realisticCoal = lineage == Lineage.MINERALOGY_110 + && bool(values, "realistic_coal_layers", false); + int geomeSize = integer(values, "geome_size", 100, 4, Short.MAX_VALUE); + double rockLayerNoise = decimal(values, "rock_layer_noise", 32.0D, + 1.0D, Short.MAX_VALUE); + int layerThickness = integer(values, "rock_layer_thickness", 8, 1, 255); + GeologyMode engine = lineage == Lineage.MINERALOGY_5 + ? geologyMode(values, warnings) : GeologyMode.LEGACY; + + List igneous = effectiveList(lineage.igneous, values, + "igneous_whitelist", "igneous_blacklist", lineage == Lineage.MINERALOGY_5); + List metamorphic = effectiveList(lineage.metamorphic, values, + "metamorphic_whitelist", "metamorphic_blacklist", lineage == Lineage.MINERALOGY_5); + List sedimentaryBase; + if (lineage == Lineage.MINERALOGY_110) { + sedimentaryBase = new ArrayList<>(SEDIMENTARY_110_BEFORE_COAL); + if (realisticCoal) sedimentaryBase.add("minecraft:coal_ore"); + sedimentaryBase.addAll(SEDIMENTARY_110_AFTER_COAL); + } else { + sedimentaryBase = new ArrayList<>(lineage.sedimentary); + } + List sedimentary = effectiveList(sedimentaryBase, values, + "sedimentary_whitelist", "sedimentary_blacklist", + lineage == Lineage.MINERALOGY_5); + + JsonObject root = installedPackProfile.rootCopy(); + root.addProperty("geology_mode", engine.name().toLowerCase(Locale.ROOT)); + JsonObject cyano = root.has("cyano") && root.get("cyano").isJsonObject() + ? root.getAsJsonObject("cyano") : new JsonObject(); + cyano.addProperty("enabled", enabled); + cyano.addProperty("geome_size", geomeSize); + cyano.addProperty("rock_layer_noise", rockLayerNoise); + cyano.addProperty("rock_layer_thickness", layerThickness); + cyano.addProperty("realistic_coal_layers", realisticCoal); + cyano.addProperty("migrated_from", "mineralogy-" + identity.version); + cyano.addProperty("legacy_lineage", lineage.label); + cyano.addProperty("legacy_engine", engine.name().toLowerCase(Locale.ROOT)); + cyano.addProperty("legacy_metadata_source", identity.sourceFile); + cyano.addProperty("legacy_config_source", configPath.toAbsolutePath().toString()); + cyano.addProperty("legacy_config_found", configFound); + cyano.addProperty("hybrid_config", hybridConfig); + cyano.add("igneous_rocks", array(igneous)); + cyano.add("metamorphic_rocks", array(metamorphic)); + cyano.add("sedimentary_rocks", array(sedimentary)); + for (String key : LIST_KEYS) cyano.add(key, array(values.list(key))); + root.add("cyano", cyano); + + writeUpgradeReport(worldRoot, configPath, identity, lineage, engine, + configFound, hybridConfig, enabled, geomeSize, rockLayerNoise, + layerThickness, realisticCoal, values, igneous, metamorphic, + sedimentary, warnings); + + LOGGER.info("Existing Mineralogy {} world detected from {}; pinned OreSpawn to {} " + + "behavior (engine={}, enabled={}, geomeSize={}, layerNoise={}, " + + "layerThickness={}, realisticCoal={}, configFound={})", + identity.version, identity.sourceFile, lineage.label, engine, enabled, + geomeSize, rockLayerNoise, layerThickness, realisticCoal, configFound); + return WorldGeologyProfile.fromJson(root, installedPackProfile); + } + + private static void writeUpgradeReport(Path worldRoot, Path configPath, + MineralogyIdentity identity, Lineage lineage, GeologyMode engine, + boolean configFound, boolean hybridConfig, boolean enabled, + int geomeSize, double rockLayerNoise, int layerThickness, + boolean realisticCoal, ConfigValues values, List igneous, + List metamorphic, List sedimentary, List warnings) { + Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); + List missing = missingBlocks(igneous, metamorphic, sedimentary); + List lines = new ArrayList<>(); + lines.add("OreSpawn 4.0.6 Upgrade Report"); + lines.add("================================"); + lines.add(""); + lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); + lines.add(enabled + ? "Geology remains on the " + engineLabel(engine) + " using " + + lineage.label + " behavior." + : "Legacy Mineralogy geology was disabled and remains disabled for this world."); + lines.add("This prevents an implicit settings change between old and newly generated chunks."); + lines.add(""); + lines.add("Legacy world detection"); + lines.add("- Saved mod metadata: " + identity.sourceFile); + lines.add("- Saved Mineralogy version: " + identity.version); + lines.add("- Selected config lineage: " + lineage.label); + lines.add("- Selected engine: " + engine.name()); + lines.add("- Hybrid 1.10/1.12 keys found: " + hybridConfig); + lines.add(""); + lines.add("Legacy Mineralogy configuration"); + lines.add("- Source: " + configPath.toAbsolutePath()); + lines.add("- Source file found: " + (configFound ? "yes" + : "no; published " + lineage.label + " defaults used")); + lines.add("- Geology enabled: " + enabled); + lines.add("- Geome size: " + geomeSize); + lines.add("- Rock layer noise: " + rockLayerNoise); + lines.add("- Rock layer thickness: " + layerThickness); + lines.add("- Realistic coal layers: " + realisticCoal + + (lineage == Lineage.MINERALOGY_110 ? "" : " (not used by this lineage)")); + for (String key : LIST_KEYS) { + lines.add("- " + key + " (" + values.list(key).size() + "): " + + String.join(", ", values.list(key))); + } + lines.add(""); + lines.add("Effective rock outputs"); + lines.add("- Igneous order (" + igneous.size() + "): " + String.join(", ", igneous)); + lines.add("- Metamorphic order (" + metamorphic.size() + "): " + + String.join(", ", metamorphic)); + lines.add("- Sedimentary order (" + sedimentary.size() + "): " + + String.join(", ", sedimentary)); + lines.add(""); + if (missing.isEmpty() && warnings.isEmpty()) { + lines.add("WARNINGS: None. Every preserved rock ID is registered."); + } else { + lines.add("WARNINGS:"); + for (String warning : warnings) lines.add("- " + warning); + for (String id : missing) lines.add("- Rock ID is not currently registered: " + id); + } + lines.add(""); + lines.add("OreSpawn did not rewrite the source Mineralogy configuration or existing chunks."); + lines.add("The generated OreSpawn world profile and this report are written atomically and are byte-stable on reload."); + lines.add("To change this world's geology later, make that choice explicitly and expect a generation seam."); + writeTextAtomically(report, lines); + } + + private static String engineLabel(GeologyMode engine) { + return engine == GeologyMode.LEGACY ? "Cyano layer engine" : "Mineralogy geome engine"; + } + + @SafeVarargs + private static List missingBlocks(List... families) { + Set missing = new LinkedHashSet<>(); + for (List family : families) { + for (String idText : family) { + try { + ResourceLocation id = ResourceLocation.parse(idText); + if (!ForgeRegistries.BLOCKS.containsKey(id)) missing.add(id.toString()); + } catch (RuntimeException e) { + missing.add(idText + " (invalid registry name)"); + } + } + } + return new ArrayList<>(missing); + } + + private static void writeTextAtomically(Path report, List lines) { + Path temporary = report.resolveSibling(report.getFileName().toString() + ".tmp"); + try { + Files.createDirectories(report.getParent()); + byte[] data = (String.join(System.lineSeparator(), lines) + System.lineSeparator()) + .getBytes(StandardCharsets.UTF_8); + if (Files.isRegularFile(report) && Arrays.equals(Files.readAllBytes(report), data)) return; + Files.write(temporary, data); + try { + Files.move(temporary, report, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, report, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + try { Files.deleteIfExists(temporary); } catch (IOException ignored) { } + LOGGER.warn("Could not write legacy Mineralogy upgrade report '{}'", report, e); + } + } + + private static boolean hasGeneratedOverworldChunks(Path worldRoot) { + Path regions = worldRoot.resolve("region"); + if (!Files.isDirectory(regions)) return false; + try (DirectoryStream files = Files.newDirectoryStream(regions, "r.*.*.mca")) { + return files.iterator().hasNext(); + } catch (IOException e) { + LOGGER.warn("Could not inspect existing world regions in '{}'", regions, e); + return false; + } + } + + private static MineralogyIdentity legacyMineralogyIdentity(Path worldRoot) { + for (String fileName : new String[] { "level.dat", "level.dat_old" }) { + Path levelDat = worldRoot.resolve(fileName); + if (!Files.isRegularFile(levelDat)) continue; + try (FileInputStream input = new FileInputStream(levelDat.toFile())) { + CompoundNBT root = CompressedStreamTools.readCompressed(input); + MineralogyIdentity identity = identity(root, fileName); + if (identity != null) return identity.legacy ? identity : null; + } catch (IOException | RuntimeException e) { + LOGGER.warn("Could not inspect '{}' for legacy Mineralogy metadata", levelDat, e); + } + } + return null; + } + + private static MineralogyIdentity identity(CompoundNBT root, String sourceFile) { + for (ModListPath path : MOD_LIST_PATHS) { + CompoundNBT container = root.getCompound(path.compound); + ListNBT mods = container.getList(path.list, 10); + for (int i = 0; i < mods.size(); i++) { + CompoundNBT mod = mods.getCompound(i); + String id = firstNonBlank(mod.getString("ModId"), mod.getString("modid")); + if (!"mineralogy".equalsIgnoreCase(id)) continue; + String version = firstNonBlank(mod.getString("ModVersion"), mod.getString("version")).trim(); + return new MineralogyIdentity(version.isEmpty() ? "legacy" : version, + sourceFile + " (" + path.compound + "/" + path.list + ")", + isLegacyVersion(version)); + } + } + return null; + } + + private static boolean isLegacyVersion(String version) { + if (version == null || version.trim().isEmpty() || "legacy".equalsIgnoreCase(version)) return true; + List parts = versionParts(version); + return !parts.isEmpty() && (parts.get(0) == 3 || parts.get(0) == 5); + } + + private static List versionParts(String version) { + List result = new ArrayList<>(); + if (version == null) return result; + for (String text : version.split("[^0-9]+")) { + if (text.isEmpty()) continue; + try { result.add(Integer.parseInt(text)); } + catch (NumberFormatException ignored) { } + } + return result; + } + + private static ConfigValues readForgeCfg(Path path) { + ConfigValues values = new ConfigValues(); + if (!Files.isRegularFile(path)) return values; + try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (trimmed.length() < 4 || trimmed.charAt(1) != ':') continue; + char type = Character.toUpperCase(trimmed.charAt(0)); + if (type != 'B' && type != 'I' && type != 'D' && type != 'S') continue; + int equals = trimmed.indexOf('=', 2); + if (equals <= 2) continue; + String key = normalizeKey(trimmed.substring(2, equals)); + String value = trimmed.substring(equals + 1).trim(); + if (isListKey(key)) values.lists.put(key, parseDelimitedList(value, ";")); + else values.scalars.put(key, value); + } + } catch (IOException e) { + LOGGER.warn("Could not read legacy Mineralogy configuration '{}'; using published defaults", path, e); + values.clear(); + } + return values; + } + + private static ConfigValues readToml(Path path) { + ConfigValues values = new ConfigValues(); + if (!Files.isRegularFile(path)) return values; + try { + List lines = Files.readAllLines(path, StandardCharsets.UTF_8); + for (int i = 0; i < lines.size(); i++) { + String line = stripTomlComment(lines.get(i)).trim(); + if (line.isEmpty() || line.startsWith("[")) continue; + int equals = indexOutsideQuotes(line, '='); + if (equals <= 0) continue; + String key = normalizeKey(line.substring(0, equals)); + String value = line.substring(equals + 1).trim(); + if (value.startsWith("[") && !arrayComplete(value)) { + StringBuilder joined = new StringBuilder(value); + while (++i < lines.size()) { + joined.append(' ').append(stripTomlComment(lines.get(i)).trim()); + if (arrayComplete(joined.toString())) break; + } + value = joined.toString(); + } + if (isListKey(key)) values.lists.put(key, parseTomlArray(value)); + else values.scalars.put(key, unquote(value)); + } + } catch (IOException e) { + LOGGER.warn("Could not read legacy Mineralogy TOML configuration '{}'; using published defaults", path, e); + values.clear(); + } + return values; + } + + private static String stripTomlComment(String line) { + boolean quoted = false; + boolean escaped = false; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (escaped) { escaped = false; continue; } + if (c == '\\' && quoted) { escaped = true; continue; } + if (c == '"') quoted = !quoted; + else if (c == '#' && !quoted) return line.substring(0, i); + } + return line; + } + + private static int indexOutsideQuotes(String text, char wanted) { + boolean quoted = false; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) quoted = !quoted; + if (c == wanted && !quoted) return i; + } + return -1; + } + + private static boolean arrayComplete(String text) { + boolean quoted = false; + int depth = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) quoted = !quoted; + if (!quoted && c == '[') depth++; + if (!quoted && c == ']') depth--; + } + return depth <= 0 && !quoted; + } + + private static List parseTomlArray(String value) { + String trimmed = value.trim(); + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return Collections.emptyList(); + trimmed = trimmed.substring(1, trimmed.length() - 1); + List result = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean quoted = false; + boolean escaped = false; + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (escaped) { current.append(c); escaped = false; continue; } + if (c == '\\' && quoted) { escaped = true; continue; } + if (c == '"') { quoted = !quoted; continue; } + if (c == ',' && !quoted) { + addConfiguredId(result, current.toString()); + current.setLength(0); + } else current.append(c); + } + addConfiguredId(result, current.toString()); + return result; + } + + private static List parseDelimitedList(String value, String delimiter) { + List result = new ArrayList<>(); + for (String entry : value.split(java.util.regex.Pattern.quote(delimiter), -1)) { + addConfiguredId(result, entry); + } + return result; + } + + private static void addConfiguredId(List result, String raw) { + String value = unquote(raw.trim()); + if (value.isEmpty()) return; + try { result.add(ResourceLocation.parse(value).toString()); } + catch (RuntimeException e) { + LOGGER.warn("Ignoring invalid legacy Mineralogy rock registry name '{}'", value); + } + } + + private static String unquote(String value) { + String trimmed = value.trim(); + if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.substring(1, trimmed.length() - 1) + .replace("\\\"", "\"").replace("\\\\", "\\"); + } + return trimmed; + } + + private static GeologyMode geologyMode(ConfigValues values, List warnings) { + String configured = values.scalar("geology_mode"); + if (configured == null || configured.trim().isEmpty()) return GeologyMode.GEOME; + try { return GeologyMode.valueOf(configured.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException e) { + warnings.add("Invalid GEOLOGY_MODE '" + configured + "'; published GEOME default used."); + return GeologyMode.GEOME; + } + } + + private static List effectiveList(List defaults, ConfigValues values, + String whitelistKey, String blacklistKey, boolean deduplicateWhitelist) { + List result = new ArrayList<>(defaults); + for (String id : values.list(whitelistKey)) { + if (!deduplicateWhitelist || !result.contains(id)) result.add(id); + } + for (String id : values.list(blacklistKey)) result.remove(id); + return result; + } + + private static int integer(ConfigValues values, String key, int fallback, int min, int max) { + try { + int value = values.scalar(key) == null ? fallback : Integer.parseInt(values.scalar(key)); + return Math.max(min, Math.min(max, value)); + } catch (RuntimeException e) { return fallback; } + } + + private static double decimal(ConfigValues values, String key, + double fallback, double min, double max) { + try { + double value = values.scalar(key) == null ? fallback : Double.parseDouble(values.scalar(key)); + return Math.max(min, Math.min(max, value)); + } catch (RuntimeException e) { return fallback; } + } + + private static boolean bool(ConfigValues values, String key, boolean fallback) { + String value = values.scalar(key); + if (value == null) return fallback; + if ("true".equalsIgnoreCase(value)) return true; + if ("false".equalsIgnoreCase(value)) return false; + return fallback; + } + + private static JsonArray array(List values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String normalizeKey(String value) { + return unquote(value).trim().toLowerCase(Locale.ROOT).replace('-', '_'); + } + + private static boolean isListKey(String key) { + for (String candidate : LIST_KEYS) if (candidate.equals(key)) return true; + return false; + } + + private static String firstNonBlank(String first, String second) { + return first != null && !first.trim().isEmpty() ? first : second == null ? "" : second; + } + + private static List list(String... values) { + return Collections.unmodifiableList(Arrays.asList(values)); + } + + private static final List LIST_KEYS = list( + "igneous_whitelist", "igneous_blacklist", + "metamorphic_whitelist", "metamorphic_blacklist", + "sedimentary_whitelist", "sedimentary_blacklist"); + + private static final List MOD_LIST_PATHS = Arrays.asList( + new ModListPath("fml", "LoadingModList"), + new ModListPath("fml", "ModList"), + new ModListPath("FML", "ModList")); + + private enum Lineage { + MINERALOGY_110("Mineralogy 1.10", IGNEOUS_110, METAMORPHIC_110, + Collections.emptyList()), + MINERALOGY_112("Mineralogy 1.12", IGNEOUS_112, METAMORPHIC_112, SEDIMENTARY_112), + MINERALOGY_5("Mineralogy 5.x", IGNEOUS_5, METAMORPHIC_5, SEDIMENTARY_5); + + final String label; + final List igneous; + final List metamorphic; + final List sedimentary; + + Lineage(String label, List igneous, List metamorphic, + List sedimentary) { + this.label = label; + this.igneous = igneous; + this.metamorphic = metamorphic; + this.sedimentary = sedimentary; + } + + static Lineage forVersion(String version) { + List parts = versionParts(version); + if (!parts.isEmpty() && parts.get(0) == 5) return MINERALOGY_5; + if (parts.size() >= 2 && parts.get(0) == 3 && parts.get(1) <= 3) { + return MINERALOGY_110; + } + return MINERALOGY_112; + } + } + + private static final class ConfigValues { + final Map scalars = new LinkedHashMap<>(); + final Map> lists = new LinkedHashMap<>(); + String scalar(String key) { return scalars.get(normalizeKey(key)); } + List list(String key) { + List value = lists.get(normalizeKey(key)); + return value == null ? Collections.emptyList() : Collections.unmodifiableList(value); + } + void clear() { scalars.clear(); lists.clear(); } + } + + private static final class ModListPath { + final String compound; + final String list; + ModListPath(String compound, String list) { this.compound = compound; this.list = list; } + } + + private static final class MineralogyIdentity { + final String version; + final String sourceFile; + final boolean legacy; + MineralogyIdentity(String version, String sourceFile, boolean legacy) { + this.version = version; + this.sourceFile = sourceFile; + this.legacy = legacy; + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java index 8952e092..a9a70f94 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -92,7 +92,9 @@ public boolean place(FeaturePlaceContext context) { net.minecraft.resources.ResourceKey dimension = world.getLevel().dimension(); BakedTerrainDimension terrain = GeomeConfig.terrainDimension(dimension); BakedGeomeConfig config = GeomeConfig.baked(dimension); - if (!OreSpawnConfig.placeOreSpawnRock() || terrain == null || config == null) { + WorldGeologyProfile profile = WorldGeologyProfileManager.activeProfile(); + if (!OreSpawnConfig.placeOreSpawnRock() || terrain == null || config == null + || (profile.hasLegacyMineralogySnapshot() && !profile.cyanoEnabled())) { return false; } @@ -171,8 +173,7 @@ private CachedGeology geology(net.minecraft.resources.ResourceKey dimensi if (current == null || current.seed != seed || current.mode != mode) { WorldGeologyProfile profile = WorldGeologyProfileManager.activeProfile(); current = mode == GeologyMode.LEGACY - ? new CachedGeology(seed, mode, new Geology(seed, profile.cyanoGeomeSize(), - profile.cyanoRockLayerNoise(), profile.cyanoLayerThickness(), config), null) + ? new CachedGeology(seed, mode, new Geology(seed, profile, config), null) : new CachedGeology(seed, mode, null, new GeomeGeology(seed, config)); geologyByDimension.put(dimension, current); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java index d441bba3..9e687370 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -1,5 +1,8 @@ package zone.moddev.mc.orespawn.worldgen; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Locale; import java.util.Optional; @@ -275,6 +278,34 @@ public int cyanoLayerThickness() { return nestedInt("cyano", "rock_layer_thickness", 8, 1, 255); } + public boolean cyanoEnabled() { + return nestedBoolean("cyano", "enabled", true); + } + + public boolean cyanoRealisticCoalLayers() { + return nestedBoolean("cyano", "realistic_coal_layers", false); + } + + public boolean hasLegacyMineralogySnapshot() { + return root.has("cyano") && root.get("cyano").isJsonObject() + && root.getAsJsonObject("cyano").has("migrated_from"); + } + + boolean hasCyanoRockOrder(String key) { + return root.has("cyano") && root.get("cyano").isJsonObject() + && root.getAsJsonObject("cyano").has(key) + && root.getAsJsonObject("cyano").get(key).isJsonArray(); + } + + List cyanoRockOrder(String key) { + if (!hasCyanoRockOrder(key)) return Collections.emptyList(); + List result = new ArrayList<>(); + for (JsonElement element : root.getAsJsonObject("cyano").getAsJsonArray(key)) { + if (element.isJsonPrimitive()) result.add(element.getAsString()); + } + return Collections.unmodifiableList(result); + } + private static JsonObject recommendedFormationJson() { JsonObject formations = new JsonObject(); formations.addProperty("algorithm", Algorithm.STABLE_LAYERS.configName()); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java index c8247be8..98970943 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -27,6 +27,7 @@ import net.minecraftforge.event.server.ServerAboutToStartEvent; import net.minecraftforge.event.server.ServerStoppedEvent; import net.minecraftforge.event.level.LevelEvent; +import net.minecraftforge.fml.loading.FMLPaths; import net.minecraft.server.level.ServerLevel; import org.apache.logging.log4j.LogManager; @@ -135,15 +136,22 @@ public static void onServerAboutToStart(ServerAboutToStartEvent event) { LOGGER.info("Merged new OreSpawn worldgen-provider definitions into '{}'", profilePath); } } else { - pending = consumePendingProfile(); boolean generatedWorld = hasGeneratedOverworldChunks(worldRoot); + pending = consumePendingProfile(); String source; - if (pending != null) { + if (generatedWorld) { + WorldGeologyProfile legacyMineralogy = LegacyMineralogyProfileMigration.migrateIfNeeded( + worldRoot, FMLPaths.CONFIGDIR.get(), GeomeConfig.globalBaseProfile()); + if (legacyMineralogy != null) { + profile = legacyMineralogy; + source = "legacy Mineralogy settings (existing world)"; + } else { + profile = GeomeConfig.globalBaseProfile(); + source = "instance (existing world)"; + } + } else if (pending != null) { profile = pending; source = "Create World"; - } else if (generatedWorld) { - profile = GeomeConfig.globalBaseProfile(); - source = "instance (existing world)"; } else { profile = fallback.copy(); source = "installed-pack fresh-world"; diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java index 4933a140..d7b19230 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java @@ -57,6 +57,9 @@ void migratesBaseMetalsOs3FixtureExactly() throws IOException { assertTrue(ore(ores, "starsteel_ore").getAsJsonObject("dimensions").has("minecraft:the_end")); assertEquals(127, rule(ore(ores, "coldiron_ore")).get("max_y").getAsInt()); assertEquals(0.125D, rule(ore(ores, "platinum_ore")).get("frequency").getAsDouble()); + String upgradeReport = read(temporary.resolve("orespawn-upgrade-report.txt")); + assertTrue(upgradeReport.contains("Spawn definitions imported: 11")); + assertTrue(upgradeReport.contains("Original legacy configuration files were retained unchanged")); } @Test diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java new file mode 100644 index 00000000..9379b921 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java @@ -0,0 +1,356 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; + +class LegacyMineralogyProfileMigrationTest { + @BeforeAll + static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + void carriedMineralogy110ConfigRetainsItsExactCyanoContract(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "FML", "ModList", "3.3.8.26"); + Path config = config(root, "mineralogy.cfg", + "I:GEOME_SIZE=144\n" + + "B:REALISTIC_COAL_LAYERS=true\n" + + "S:ROCK_LAYER_NOISE=41.5\n" + + "I:ROCK_LAYER_THICKNESS=11\n" + + "S:igneous_whitelist=minecraft:obsidian;mineralogy:diabase\n" + + "S:igneous_blacklist=mineralogy:gabbro\n" + + "S:metamorphic_whitelist=minecraft:cobblestone\n" + + "S:metamorphic_blacklist=mineralogy:slate\n" + + "S:sedimentary_whitelist=minecraft:gravel\n" + + "S:sedimentary_blacklist=mineralogy:gypsum\n"); + String sourceHash = sha256(config.resolve("mineralogy.cfg")); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(GeologyMode.LEGACY, migrated.geologyMode()); + assertTrue(migrated.cyanoEnabled()); + assertTrue(migrated.cyanoRealisticCoalLayers()); + assertEquals(144, migrated.cyanoGeomeSize()); + assertEquals(41.5D, migrated.cyanoRockLayerNoise()); + assertEquals(11, migrated.cyanoLayerThickness()); + JsonObject cyano = migrated.toJson().getAsJsonObject("cyano"); + assertEquals("Mineralogy 1.10", string(cyano, "legacy_lineage")); + assertEquals(Arrays.asList("minecraft:obsidian", "mineralogy:diabase"), + strings(cyano, "igneous_whitelist")); + assertFalse(strings(cyano, "igneous_rocks").contains("mineralogy:gabbro")); + assertTrue(strings(cyano, "sedimentary_rocks").contains("minecraft:coal_ore")); + assertEquals(sourceHash, sha256(config.resolve("mineralogy.cfg"))); + assertReport(world, "Selected config lineage: Mineralogy 1.10", + "Selected engine: LEGACY", "Cyano layer engine"); + } + + @Test + void nativeMineralogy112RetainsDuplicateRockSaltAndDisabledState(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "FML", "ModList", "3.8.0.53"); + Path config = config(root, "mineralogy.cfg", + "B:PLACE_MINERALOGY_ROCK=false\n" + + "I:GEOME_SIZE=128\n" + + "S:ROCK_LAYER_NOISE=37.25\n" + + "I:ROCK_LAYER_THICKNESS=9\n"); + + WorldGeologyProfile migrated = migrate(world, config); + JsonObject cyano = migrated.toJson().getAsJsonObject("cyano"); + + assertEquals(GeologyMode.LEGACY, migrated.geologyMode()); + assertFalse(migrated.cyanoEnabled()); + assertEquals("Mineralogy 1.12", string(cyano, "legacy_lineage")); + assertEquals(2, count(strings(cyano, "sedimentary_rocks"), "mineralogy:rock_salt")); + assertReport(world, "Legacy Mineralogy geology was disabled and remains disabled", + "Source file found: yes"); + } + + @Test + void publishedMineralogy540TomlPreservesEngineNumbersAndAllLists(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.4.0"); + Path config = config(root, "mineralogy-common.toml", + "[options]\n" + + "PLACE_MINERALOGY_ROCK = true\n" + + "[world-gen]\n" + + "GEOLOGY_MODE = \"GEOME\"\n" + + "GEOME_SIZE = 196\n" + + "ROCK_LAYER_NOISE = 44.75\n" + + "ROCK_LAYER_THICKNESS = 13\n" + + "igneous_whitelist = [\"minecraft:obsidian\", \"minecraft:obsidian\", \"minecraft:netherrack\"]\n" + + "igneous_blacklist = [\"mineralogy:basalt\"]\n" + + "metamorphic_whitelist = [\"minecraft:cobblestone\"]\n" + + "metamorphic_blacklist = [\"mineralogy:slate\"]\n" + + "sedimentary_whitelist = [\n" + + " \"minecraft:gravel\", # retained comment\n" + + " \"minecraft:sand\"\n" + + "]\n" + + "sedimentary_blacklist = [\"mineralogy:gypsum\"]\n"); + + WorldGeologyProfile migrated = migrate(world, config); + JsonObject cyano = migrated.toJson().getAsJsonObject("cyano"); + + assertEquals(GeologyMode.GEOME, migrated.geologyMode()); + assertTrue(migrated.cyanoEnabled()); + assertEquals(196, migrated.cyanoGeomeSize()); + assertEquals(44.75D, migrated.cyanoRockLayerNoise()); + assertEquals(13, migrated.cyanoLayerThickness()); + assertEquals("Mineralogy 5.x", string(cyano, "legacy_lineage")); + assertEquals(Arrays.asList("minecraft:obsidian", "minecraft:obsidian", "minecraft:netherrack"), + strings(cyano, "igneous_whitelist")); + assertEquals(1, count(strings(cyano, "igneous_rocks"), "minecraft:obsidian"), + "Mineralogy 5 deduplicated whitelist additions before sampling"); + assertFalse(strings(cyano, "igneous_rocks").contains("mineralogy:basalt")); + assertEquals(Arrays.asList("minecraft:gravel", "minecraft:sand"), + strings(cyano, "sedimentary_whitelist")); + assertReport(world, "Saved mod metadata: level.dat (fml/LoadingModList)", + "Selected engine: GEOME", "Mineralogy geome engine"); + } + + @Test + void mineralogy5LegacyEngineChoiceRemainsCyano(@TempDir Path root) throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.4.0"); + Path config = config(root, "mineralogy-common.toml", + "[options]\nPLACE_MINERALOGY_ROCK = true\n" + + "[world-gen]\nGEOLOGY_MODE = \"LEGACY\"\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(GeologyMode.LEGACY, migrated.geologyMode()); + assertReport(world, "Selected engine: LEGACY", "Cyano layer engine"); + } + + @Test + void malformedMineralogy5ValuesUsePublishedDefaultsWithoutBroadening(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE = \"unknown\"\nGEOME_SIZE = \"bad\"\n" + + "ROCK_LAYER_NOISE = -2\nROCK_LAYER_THICKNESS = 9999\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(GeologyMode.GEOME, migrated.geologyMode()); + assertEquals(100, migrated.cyanoGeomeSize()); + assertEquals(1.0D, migrated.cyanoRockLayerNoise()); + assertEquals(255, migrated.cyanoLayerThickness()); + assertReport(world, "Invalid GEOLOGY_MODE 'unknown'", "published GEOME default used"); + } + + @Test + void savedLineageWinsWhenAnotherStaleConfigFileIsPresent(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy.cfg", + "B:PLACE_MINERALOGY_ROCK=false\nI:GEOME_SIZE=144\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertTrue(migrated.cyanoEnabled()); + assertEquals(100, migrated.cyanoGeomeSize()); + assertReport(world, "Source file found: no; published Mineralogy 5.x defaults used", + "Found mineralogy.cfg but saved world metadata selects Mineralogy 5.x"); + } + + @Test + void freshWorldWithStaleLegacyFilesKeepsCurrentCreateWorldChoice(@TempDir Path root) + throws Exception { + Path fresh = root.resolve("fresh"); + Files.createDirectories(fresh); + writeLevelDat(fresh, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE=\"LEGACY\"\n"); + + assertNull(migrate(fresh, config)); + } + + @Test + void existingOs4WorldProfileWinsOverStaleLegacyMetadataAndConfig(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.4.0"); + Path serverConfig = Files.createDirectories(world.resolve("serverconfig")); + Path profile = serverConfig.resolve("orespawn-worldgen.json"); + byte[] original = WorldGeologyProfile.recommended(false).toJson().toString() + .getBytes(StandardCharsets.UTF_8); + Files.write(profile, original); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE=\"LEGACY\"\nGEOME_SIZE=177\n"); + + assertNull(migrate(world, config)); + assertTrue(Arrays.equals(original, Files.readAllBytes(profile))); + assertFalse(Files.exists(serverConfig.resolve("orespawn-upgrade-report.txt"))); + } + + @Test + void validLevelDatOldIsUsedWhenCurrentMetadataCannotBeRead(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Files.move(world.resolve("level.dat"), world.resolve("level.dat_old")); + Files.write(world.resolve("level.dat"), new byte[] { 1, 2, 3, 4 }); + Path config = config(root, "mineralogy-common.toml", "[world-gen]\nGEOME_SIZE=121\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(121, migrated.cyanoGeomeSize()); + assertReport(world, "Saved mod metadata: level.dat_old (fml/LoadingModList)"); + } + + @Test + void modernMineralogyWorldIsNotReclassifiedByOldFiles(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "6.0.0"); + Path config = config(root, "mineralogy.cfg", "I:GEOME_SIZE=144\n"); + assertNull(migrate(world, config)); + } + + @Test + void migrationAndReportAreByteStableAndSourceRemainsUntouched(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE=\"LEGACY\"\nGEOME_SIZE=111\n"); + Path source = config.resolve("mineralogy-common.toml"); + String sourceHash = sha256(source); + + WorldGeologyProfile first = migrate(world, config); + Path report = world.resolve("serverconfig/orespawn-upgrade-report.txt"); + byte[] reportBytes = Files.readAllBytes(report); + WorldGeologyProfile second = migrate(world, config); + + assertEquals(first.toJson(), second.toJson()); + assertTrue(Arrays.equals(reportBytes, Files.readAllBytes(report))); + assertEquals(sourceHash, sha256(source)); + } + + @Test + void snapshottedOrderPreservesDuplicatesAndFallsBackOnlyForEmptyFamily() { + WorldGeologyProfile base = WorldGeologyProfile.recommended(false); + JsonObject root = base.rootCopy(); + JsonObject cyano = new JsonObject(); + cyano.add("sedimentary_rocks", array( + "minecraft:sandstone", "minecraft:coal_ore", "minecraft:sandstone")); + cyano.addProperty("migrated_from", "test"); + root.add("cyano", cyano); + BlockState[] resolved = Geology.resolveRockOrder(base.withRoot(root), + "sedimentary_rocks", new BlockState[] { Blocks.BEDROCK.defaultBlockState() }); + assertEquals(3, resolved.length); + assertEquals(Blocks.SANDSTONE, resolved[0].getBlock()); + assertEquals(Blocks.COAL_ORE, resolved[1].getBlock()); + assertEquals(Blocks.SANDSTONE, resolved[2].getBlock()); + + JsonObject missingRoot = base.rootCopy(); + JsonObject missingCyano = new JsonObject(); + missingCyano.add("igneous_rocks", array("missingmod:removed_rock")); + missingCyano.addProperty("migrated_from", "test"); + missingRoot.add("cyano", missingCyano); + BlockState[] fallback = { Blocks.OBSIDIAN.defaultBlockState() }; + assertEquals(Blocks.OBSIDIAN, Geology.resolveRockOrder(base.withRoot(missingRoot), + "igneous_rocks", fallback)[0].getBlock()); + } + + private static WorldGeologyProfile migrate(Path world, Path config) { + return LegacyMineralogyProfileMigration.migrateIfNeeded( + world, config, WorldGeologyProfile.recommended(false)); + } + + private static Path existingWorld(Path root, String compound, String list, + String version) throws IOException { + Path world = root.resolve("world"); + Files.createDirectories(world.resolve("region")); + Files.write(world.resolve("region/r.0.0.mca"), new byte[] { 0 }); + writeLevelDat(world, compound, list, version); + return world; + } + + private static Path config(Path root, String name, String contents) throws IOException { + Path config = root.resolve("config"); + Files.createDirectories(config); + Files.write(config.resolve(name), contents.getBytes(StandardCharsets.UTF_8)); + return config; + } + + private static void writeLevelDat(Path world, String compound, String list, + String version) throws IOException { + CompoundTag root = new CompoundTag(); + CompoundTag fml = new CompoundTag(); + ListTag mods = new ListTag(); + CompoundTag mod = new CompoundTag(); + mod.putString("ModId", "mineralogy"); + mod.putString("ModVersion", version); + mods.add(mod); + fml.put(list, mods); + root.put(compound, fml); + try (FileOutputStream output = new FileOutputStream(world.resolve("level.dat").toFile())) { + NbtIo.writeCompressed(root, output); + } + } + + private static void assertReport(Path world, String... fragments) throws IOException { + String report = new String(Files.readAllBytes( + world.resolve("serverconfig/orespawn-upgrade-report.txt")), StandardCharsets.UTF_8); + for (String fragment : fragments) assertTrue(report.contains(fragment), fragment); + } + + private static String string(JsonObject parent, String key) { + return parent.get(key).getAsString(); + } + + private static List strings(JsonObject parent, String key) { + List result = new ArrayList<>(); + for (JsonElement value : parent.getAsJsonArray(key)) result.add(value.getAsString()); + return result; + } + + private static int count(List values, String expected) { + int count = 0; + for (String value : values) if (expected.equals(value)) count++; + return count; + } + + private static JsonArray array(String... values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String sha256(Path path) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] result = digest.digest(Files.readAllBytes(path)); + StringBuilder hex = new StringBuilder(); + for (byte value : result) hex.append(String.format("%02X", value)); + return hex.toString(); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java index 1d315f70..f87bb67b 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java @@ -342,6 +342,55 @@ void worldProfileRefreshPreservesOriginalAndCustomRules(@TempDir Path temporaryD persisted.get("ore_defaults_revision").getAsInt()); } + @Test + void os404GlobalOnlyProfilePreservesCustomValuesAndProviderDefinitions() { + JsonObject global = completeGlobalFixture(); + global.getAsJsonObject("formations").addProperty("edge_irregularity", "custom"); + global.getAsJsonObject("formations").getAsJsonObject("custom") + .addProperty("edge_amplitude", 37.25D); + JsonObject provider = new JsonObject(); + provider.addProperty("provider_revision", 44); + global.getAsJsonObject("providers").add("example:rocks", provider); + + JsonObject result = WorldGeologyProfile.fromGlobalConfig(global, + GeologyMode.GEOME, false).toJson(); + + assertEquals(37.25D, result.getAsJsonObject("formations") + .getAsJsonObject("custom").get("edge_amplitude").getAsDouble()); + assertEquals(44, result.getAsJsonObject("providers") + .getAsJsonObject("example:rocks").get("provider_revision").getAsInt()); + } + + @Test + void os404WorldProfileWinsOverGlobalAndReloadsByteStable(@TempDir Path temporaryDirectory) + throws IOException { + JsonObject global = completeGlobalFixture(); + global.getAsJsonObject("formations").getAsJsonObject("custom") + .addProperty("edge_amplitude", 91.0D); + JsonObject world = completeGlobalFixture(); + world.getAsJsonObject("formations").addProperty("edge_irregularity", "custom"); + world.getAsJsonObject("formations").getAsJsonObject("custom") + .addProperty("edge_amplitude", 23.5D); + JsonObject provider = new JsonObject(); + provider.addProperty("provider_revision", 17); + world.getAsJsonObject("providers").add("example:world_provider", provider); + Path profilePath = temporaryDirectory.resolve("orespawn-worldgen.json"); + Files.write(profilePath, world.toString().getBytes(StandardCharsets.UTF_8)); + WorldGeologyProfile fallback = WorldGeologyProfile.fromGlobalConfig(global, + GeologyMode.GEOME, true); + + WorldGeologyProfile first = WorldGeologyProfileManager.readProfile(profilePath, fallback); + byte[] persisted = Files.readAllBytes(profilePath); + WorldGeologyProfile second = WorldGeologyProfileManager.readProfile(profilePath, fallback); + + assertEquals(23.5D, first.toJson().getAsJsonObject("formations") + .getAsJsonObject("custom").get("edge_amplitude").getAsDouble()); + assertEquals(17, first.toJson().getAsJsonObject("providers") + .getAsJsonObject("example:world_provider").get("provider_revision").getAsInt()); + assertEquals(first.toJson(), second.toJson()); + assertTrue(Arrays.equals(persisted, Files.readAllBytes(profilePath))); + } + @Test void oreDefaultsUpgradeCanonicalPluralPatternNames() { JsonObject original = oreDefaultsFixture(12.0D); From 39ee5544a3efaaaa6ca968fda18f949ff0bd8ff0 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 13 Aug 2026 13:42:39 +0100 Subject: [PATCH 2/3] Audit Forge 26.1.2 runtime logs and fluid writes --- build.gradle | 172 ++++++++++++++++- .../orespawn/testmod/SurfaceProbeTestMod.java | 44 ++++- .../moddev/mc/orespawn/worldgen/Geology.java | 4 +- .../worldgen/LegacyConfigMigrator.java | 1 + .../LegacyMineralogyProfileMigration.java | 27 +-- .../worldgen/LegacyConfigMigratorTest.java | 4 + .../LegacyMineralogyGeologyParityTest.java | 179 ++++++++++++++++++ src/test/resources/log4j2-test.xml | 13 ++ 8 files changed, 425 insertions(+), 19 deletions(-) create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java create mode 100644 src/test/resources/log4j2-test.xml diff --git a/build.gradle b/build.gradle index f7a9c563..f688d119 100644 --- a/build.gradle +++ b/build.gradle @@ -229,6 +229,168 @@ tasks.named('javadoc', Javadoc).configure { tasks.named('test', Test).configure { useJUnitPlatform() + // Unit tests inspect target files relative to the checkout, but they do + // not need Forge's rolling runtime files. A console-only test logger keeps + // them from contending with Eclipse/client logs in this working directory. + systemProperty 'log4j.configurationFile', file('src/test/resources/log4j2-test.xml').absolutePath + // Loaded only through an isolated URLClassLoader by the parity test. This + // is deliberately not a Gradle dependency and cannot leak into Eclipse or + // a published OreSpawn jar. + File mineralogy5Oracle = file('../../MinecraftMineralogy 118/MinecraftMineralogy/build/libs/Mineralogy-1.18.2-5.4.0.jar') + if (mineralogy5Oracle.isFile()) { + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5Oracle.absolutePath + } +} + +// Several registry-focused tests initialize the real global config singleton. +// Keep that target-native coverage without creating or changing a developer's +// checkout config as a side effect of `test` or `build`. +def unitTestWorldgenConfig = file('config/orespawn-worldgen.json') +def unitTestWorldgenConfigWasPresent = false +byte[] unitTestWorldgenConfigBytes = null +tasks.named('test', Test).configure { + doFirst { + unitTestWorldgenConfigWasPresent = unitTestWorldgenConfig.isFile() + unitTestWorldgenConfigBytes = unitTestWorldgenConfigWasPresent + ? unitTestWorldgenConfig.bytes : null + } +} +def preserveDeveloperWorldgenConfig = tasks.register('preserveDeveloperWorldgenConfig') { + doLast { + if (unitTestWorldgenConfigWasPresent) { + byte[] after = unitTestWorldgenConfig.isFile() ? unitTestWorldgenConfig.bytes : null + if (after == null || !java.util.Arrays.equals(unitTestWorldgenConfigBytes, after)) { + unitTestWorldgenConfig.parentFile.mkdirs() + unitTestWorldgenConfig.bytes = unitTestWorldgenConfigBytes + throw new GradleException('Unit tests changed config/orespawn-worldgen.json; the original was restored') + } + } else if (unitTestWorldgenConfig.isFile()) { + delete unitTestWorldgenConfig + } + } +} +tasks.named('test') { + finalizedBy preserveDeveloperWorldgenConfig +} + +// A Forge process is not green merely because it returns exit code zero. The +// loader can log a worldgen/linkage failure and still shut down normally. +def acceptedForge40LogNoise = [ + ~/FML appears to be missing any signature data/, + ~/Found multiple arguments for option fml\.mcVersion/, + ~/Found multiple arguments for option fml\.forgeVersion/, + ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, + ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / +] + +def runtimeCrashSnapshot = { File runDirectory -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (!crashDirectory.isDirectory()) return [] as Set + return fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() }.collect { it.absolutePath } as Set +} + +def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (crashDirectory.isDirectory()) { + def crashes = fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } + if (!crashes.isEmpty()) { + throw new GradleException("${context} produced crash report ${crashes.first()}") + } + } + + File logsDirectory = new File(runDirectory, 'logs') + if (!logsDirectory.isDirectory()) return + def failures = [] + [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] + .findAll { it.isFile() }.each { File log -> + int lineNumber = 0 + log.eachLine('UTF-8') { String line -> + lineNumber++ + boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ + boolean knownNoise = acceptedForge40LogNoise.any { line =~ it } + boolean fatalText = line.contains('Encountered an unexpected exception') || + line.contains('Exception stopping the server') || + line.contains('Migration audit failed') || + line.contains('java.lang.Error:') || + line.contains('NoSuchMethodError') || + line.contains('NoClassDefFoundError') || + line.contains('ExceptionInInitializerError') || + line.contains('Tried to assign a mutable BlockPos') || + line.contains('causing cascading worldgen lag') + if ((unexpectedSeverity && !knownNoise) || fatalText) { + failures.add("${log.name}:${lineNumber}: ${line}") + } + } + } + if (!failures.isEmpty()) { + throw new GradleException("${context} logged unexpected errors:\n" + + failures.take(20).join('\n')) + } +} + +task runtimeLogScannerTest { + group = 'verification' + description = 'Proves runtime log validation accepts documented Forge noise and rejects real failures.' + doLast { + File probe = file("${buildDir}/runtime-log-scanner-test") + delete probe + File logs = new File(probe, 'logs'); logs.mkdirs() + new File(logs, 'latest.log').setText( + '[main/ERROR] [FML]: FML appears to be missing any signature data\n' + + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) + new File(logs, 'latest.log').setText( + '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') + boolean rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') + new File(logs, 'latest.log').setText( + '[Server thread/DEBUG] [FML]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') + new File(logs, 'latest.log').setText( + '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') + delete probe + } +} + +check.dependsOn runtimeLogScannerTest + +task verifyMineralogyOracleIsolation { + group = 'verification' + description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' + doLast { + configurations.each { configuration -> + if (configuration.canBeResolved && + configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { + throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") + } + } + } +} + +check.dependsOn verifyMineralogyOracleIsolation + +['runClient', 'runServer', 'runData'].each { String taskName -> + tasks.matching { it.name == taskName }.all { JavaExec runTask -> + doFirst { + new File(runTask.workingDir, 'mods').mkdirs() + runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) + } + doLast { + assertRuntimeLogsClean(runTask.workingDir, taskName, + runTask.ext.oreSpawnCrashSnapshot as Set) + } + } } def surfaceIntegrationClasses = layout.buildDirectory.dir('surface-integration-fixture/classes') @@ -273,8 +435,16 @@ tasks.configureEach { } else if (name == 'runSurfaceIntegrationReload') { dependsOn 'runSurfaceIntegrationFresh' } + if (name == 'runSurfaceIntegrationFresh' || name == 'runSurfaceIntegrationReload') { + doFirst { + ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(workingDir) + } + doLast { + assertRuntimeLogsClean(workingDir, "Forge 64 ${name}", + ext.oreSpawnCrashSnapshot as Set) + } + } } - def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { group = 'verification' description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java index d36f784b..443bb740 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -75,6 +75,7 @@ public final class SurfaceProbeTestMod { private static final Identifier BIOME_A = Identifier.parse(MODID + ":surface_a"); private static final Identifier BIOME_B = Identifier.parse(MODID + ":surface_b"); private static final Identifier PROBE_GEOME = Identifier.parse(MODID + ":dynamic_biome_geome"); + private static final Identifier DYNAMIC_FLUID = Identifier.parse(MODID + ":fluid/dynamic_water"); private static final Identifier[] BUILT_IN_GEOMES = { Identifier.parse("orespawn:stable_craton"), Identifier.parse("orespawn:mountain_belt"), Identifier.parse("orespawn:volcanic_arc"), Identifier.parse("orespawn:sedimentary_basin"), @@ -106,8 +107,20 @@ public SurfaceProbeTestMod(FMLJavaModLoadingContext context) { private void enqueueProvider(InterModEnqueueEvent event) { WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); addDynamicBiomeGeology(provider); + provider.fluidDeposit(DYNAMIC_FLUID, blockId(Blocks.WATER), deposit -> deposit + .dimension(OPEN_ID, placement -> placement + .yRange(16, 24) + .attempts(12.0D) + .radius(1, 1) + .verticalRadius(1, 1) + .maxLobes(1) + .minSolidCover(1) + .minSolidShell(1) + .hostBlock(blockId(Blocks.CALCITE)))); addPalette(provider, "open_palette", OPEN_ID, false); addPalette(provider, "roofed_palette", ROOFED_ID, true); + provider.dimensionMaterials(Identifier.parse(MODID + ":materials/nether"), ROOFED_ID, + materials -> materials.defaultFluid(blockId(Blocks.WATER))); if (!OreSpawnApi.enqueue(provider.build())) { throw new IllegalStateException("Could not enqueue surface probe provider"); } @@ -143,6 +156,7 @@ private void enableGeologyProbe(ServerAboutToStartEvent event) { throw new IllegalStateException("Could not read the test-owned End geology profile", exception); } try { + root.addProperty("place_fluid_deposits", true); JsonObject terrain = root.getAsJsonObject("terrain_dimensions"); if (terrain == null) { terrain = new JsonObject(); @@ -345,8 +359,31 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { + ", sentinels=" + sentinels + ", geology=" + geology + ", ceiling=" + ceiling + ", roofTop=" + roofTop); } + long aquiferFluid = roofed ? 0L : auditDynamicFluid(level); return new AuditResult(top, underwater, filler, geology, ceiling, roofTop, - biomeA, biomeB, edgeChanges, sentinels); + biomeA, biomeB, edgeChanges, sentinels, aquiferFluid); + } + + private static long auditDynamicFluid(ServerLevel level) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + long water = 0L; + for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { + for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { + level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, true); + LevelChunk chunk = level.getChunk(chunkX, chunkZ); + for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++) { + for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) { + for (int y = 12; y <= 30; y++) { + if (chunk.getBlockState(pos.set(x, y, z)).is(Blocks.WATER)) water++; + } + } + } + } + } + if (water == 0L) { + throw new IllegalStateException("Forge 64 dynamic fluid deposit produced no covered flowing-water blocks"); + } + return water; } private static int auditSentinels(ServerLevel level, LevelChunk chunk, @@ -460,6 +497,7 @@ private static Properties properties(long seed, Map results values.setProperty(prefix + "biome_b", Integer.toString(result.biomeB())); values.setProperty(prefix + "edge_changes", Integer.toString(result.edgeChanges())); values.setProperty(prefix + "sentinels", Integer.toString(result.sentinels())); + values.setProperty(prefix + "aquifer_fluid", Long.toString(result.aquiferFluid())); } return values; } @@ -532,7 +570,7 @@ private static boolean prepareTerrain(WorldGenLevel world, ChunkAccess chunk) { chunk.setBlockState(pos.set(x, groundY - depth, z), Blocks.DIRT.defaultBlockState(), 0); } if (!roofed) { - for (int depth = 6; depth <= 8; depth++) { + for (int depth = 6; depth <= 60 && groundY - depth >= 1; depth++) { chunk.setBlockState(pos.set(x, groundY - depth, z), Blocks.END_STONE.defaultBlockState(), 0); } } @@ -605,5 +643,5 @@ private record Material(BlockState top, BlockState filler, private record AuditResult(long top, long underwater, long filler, long geology, long ceiling, long roofTop, int biomeA, int biomeB, - int edgeChanges, int sentinels) { } + int edgeChanges, int sentinels, long aquiferFluid) { } } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java index 8b11d3b8..1dc6df01 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java @@ -170,11 +170,11 @@ static BlockState[] resolveRockOrder(WorldGeologyProfile profile, String key, List states = new ArrayList<>(); for (String idText : profile.cyanoRockOrder(key)) { try { - ResourceLocation id = ResourceLocation.parse(idText); + Identifier id = Identifier.parse(idText); Block block = ForgeRegistries.BLOCKS.containsKey(id) ? ForgeRegistries.BLOCKS.getValue(id) : null; if (block != null && block != Blocks.AIR) { - states.add(block.getDefaultState()); + states.add(block.defaultBlockState()); } else { LOGGER.warn("Legacy Mineralogy rock '{}' is not registered and will be omitted", id); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java index d163b692..bda9a050 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -10,6 +10,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Locale; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java index ade51d98..3988ec0a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -23,10 +23,11 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; -import net.minecraft.nbt.CompressedStreamTools; -import net.minecraft.nbt.CompoundNBT; -import net.minecraft.nbt.ListNBT; -import net.minecraft.util.ResourceLocation; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.resources.Identifier; import net.minecraftforge.registries.ForgeRegistries; import org.apache.logging.log4j.LogManager; @@ -267,7 +268,7 @@ private static List missingBlocks(List... families) { for (List family : families) { for (String idText : family) { try { - ResourceLocation id = ResourceLocation.parse(idText); + Identifier id = Identifier.parse(idText); if (!ForgeRegistries.BLOCKS.containsKey(id)) missing.add(id.toString()); } catch (RuntimeException e) { missing.add(idText + " (invalid registry name)"); @@ -313,7 +314,7 @@ private static MineralogyIdentity legacyMineralogyIdentity(Path worldRoot) { Path levelDat = worldRoot.resolve(fileName); if (!Files.isRegularFile(levelDat)) continue; try (FileInputStream input = new FileInputStream(levelDat.toFile())) { - CompoundNBT root = CompressedStreamTools.readCompressed(input); + CompoundTag root = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); MineralogyIdentity identity = identity(root, fileName); if (identity != null) return identity.legacy ? identity : null; } catch (IOException | RuntimeException e) { @@ -323,15 +324,15 @@ private static MineralogyIdentity legacyMineralogyIdentity(Path worldRoot) { return null; } - private static MineralogyIdentity identity(CompoundNBT root, String sourceFile) { + private static MineralogyIdentity identity(CompoundTag root, String sourceFile) { for (ModListPath path : MOD_LIST_PATHS) { - CompoundNBT container = root.getCompound(path.compound); - ListNBT mods = container.getList(path.list, 10); + CompoundTag container = root.getCompoundOrEmpty(path.compound); + ListTag mods = container.getListOrEmpty(path.list); for (int i = 0; i < mods.size(); i++) { - CompoundNBT mod = mods.getCompound(i); - String id = firstNonBlank(mod.getString("ModId"), mod.getString("modid")); + CompoundTag mod = mods.getCompoundOrEmpty(i); + String id = firstNonBlank(mod.getStringOr("ModId", ""), mod.getStringOr("modid", "")); if (!"mineralogy".equalsIgnoreCase(id)) continue; - String version = firstNonBlank(mod.getString("ModVersion"), mod.getString("version")).trim(); + String version = firstNonBlank(mod.getStringOr("ModVersion", ""), mod.getStringOr("version", "")).trim(); return new MineralogyIdentity(version.isEmpty() ? "legacy" : version, sourceFile + " (" + path.compound + "/" + path.list + ")", isLegacyVersion(version)); @@ -479,7 +480,7 @@ private static List parseDelimitedList(String value, String delimiter) { private static void addConfiguredId(List result, String raw) { String value = unquote(raw.trim()); if (value.isEmpty()) return; - try { result.add(ResourceLocation.parse(value).toString()); } + try { result.add(Identifier.parse(value).toString()); } catch (RuntimeException e) { LOGGER.warn("Ignoring invalid legacy Mineralogy rock registry name '{}'", value); } diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java index d7b19230..20d78920 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java @@ -216,4 +216,8 @@ private static JsonObject rule(JsonObject ore) { return ore.getAsJsonObject("dimensions").entrySet().iterator().next() .getValue().getAsJsonObject(); } + + private static String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java new file mode 100644 index 00000000..9bdef311 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -0,0 +1,179 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.registries.ForgeRegistries; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class LegacyMineralogyGeologyParityTest { + private static final String SEALED_VECTOR_SHA256 = + "FE97624A94338C9B7E6EE6EE018A48C13920C7E55012BDB5B05A8584DFA93F5C"; + + @BeforeAll + static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + void cyanoSamplerMatchesPublishedMineralogy540AndSealedVectors() throws Exception { + Block[] igneous = { Blocks.STONE, Blocks.OBSIDIAN, Blocks.NETHERRACK }; + Block[] metamorphic = { Blocks.COBBLESTONE, Blocks.MOSSY_COBBLESTONE }; + Block[] sedimentary = { Blocks.SANDSTONE, Blocks.GRAVEL, Blocks.COAL_ORE, + Blocks.SANDSTONE }; + MessageDigest sealed = MessageDigest.getInstance("SHA-256"); + + String configuredPath = System.getProperty("orespawn.mineralogy5Oracle", ""); + Path oracle = configuredPath.trim().isEmpty() ? null : Paths.get(configuredPath); + PublishedMineralogy published = oracle != null && Files.isRegularFile(oracle) + ? PublishedMineralogy.open(oracle) : null; + try { + if (published != null) published.configure(9, igneous, metamorphic, sedimentary); + for (long seed : new long[] { 0L, -4965128775892001975L }) { + Geology os4 = new Geology(seed, 128.0D, 37.25D, 9, false, + states(igneous), states(metamorphic), states(sedimentary)); + PublishedSampler sampler = published == null ? null : published.newSampler(seed, 128.0D, 37.25D); + for (int x : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { + for (int z : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { + for (int y = 0; y < 256; y += 7) { + Block actual = os4.getStoneAt(x, y, z); + update(sealed, seed, x, y, z, actual); + if (sampler != null) { + assertEquals(sampler.getStoneAt(x, y, z), actual, + "Published Mineralogy 5.4.0 mismatch at " + + seed + ":" + x + ":" + y + ":" + z); + } + } + } + } + } + } finally { + if (published != null) published.close(); + } + + assertEquals(SEALED_VECTOR_SHA256, hex(sealed.digest()), + "The sealed vector digest is generated from the exact published Mineralogy 5.4.0 sampler"); + if (oracle != null) { + assertTrue(Files.isRegularFile(oracle), "Configured Mineralogy oracle is missing: " + oracle); + } + } + + private static void update(MessageDigest digest, long seed, int x, int y, int z, Block block) { + String id = ForgeRegistries.BLOCKS.getKey(block).toString(); + digest.update((seed + ":" + x + ":" + y + ":" + z + "=" + id + "\n") + .getBytes(StandardCharsets.UTF_8)); + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte value : bytes) result.append(String.format("%02X", value)); + return result.toString(); + } + + private static BlockState[] states(Block[] blocks) { + BlockState[] states = new BlockState[blocks.length]; + for (int i = 0; i < blocks.length; i++) states[i] = blocks[i].defaultBlockState(); + return states; + } + + private static final class PublishedMineralogy implements AutoCloseable { + private final URLClassLoader loader; + private final Class geologyClass; + private final List igneous; + private final List metamorphic; + private final List sedimentary; + private final Field thickness; + private final List originalIgneous; + private final List originalMetamorphic; + private final List originalSedimentary; + private final int originalThickness; + + @SuppressWarnings("unchecked") + private PublishedMineralogy(URLClassLoader loader) throws Exception { + this.loader = loader; + geologyClass = Class.forName("com.mcmoddev.mineralogy.worldgen.Geology", true, loader); + Class registry = Class.forName("com.mcmoddev.mineralogy.init.MineralogyRegistry", true, loader); + igneous = (List) registry.getField("igneousStones").get(null); + metamorphic = (List) registry.getField("metamorphicStones").get(null); + sedimentary = (List) registry.getField("sedimentaryStones").get(null); + originalIgneous = new ArrayList<>(igneous); + originalMetamorphic = new ArrayList<>(metamorphic); + originalSedimentary = new ArrayList<>(sedimentary); + Class config = Class.forName("com.mcmoddev.mineralogy.MineralogyConfig", true, loader); + thickness = config.getDeclaredField("geomLayerThickness"); + thickness.setAccessible(true); + originalThickness = thickness.getInt(null); + } + + static PublishedMineralogy open(Path jar) throws Exception { + URLClassLoader loader = new URLClassLoader(new URL[] { jar.toUri().toURL() }, + LegacyMineralogyGeologyParityTest.class.getClassLoader()); + try { return new PublishedMineralogy(loader); } + catch (Throwable failure) { loader.close(); throw failure; } + } + + void configure(int layerThickness, Block[] igneousValues, + Block[] metamorphicValues, Block[] sedimentaryValues) throws Exception { + reset(igneous, igneousValues); + reset(metamorphic, metamorphicValues); + reset(sedimentary, sedimentaryValues); + thickness.setInt(null, layerThickness); + } + + PublishedSampler newSampler(long seed, double geomeSize, double layerNoise) + throws Exception { + Constructor constructor = geologyClass.getConstructor( + long.class, double.class, double.class); + Object delegate = constructor.newInstance(seed, geomeSize, layerNoise); + return new PublishedSampler(delegate, + geologyClass.getMethod("getStoneAt", int.class, int.class, int.class)); + } + + @Override + public void close() throws Exception { + reset(igneous, originalIgneous.toArray(new Block[0])); + reset(metamorphic, originalMetamorphic.toArray(new Block[0])); + reset(sedimentary, originalSedimentary.toArray(new Block[0])); + thickness.setInt(null, originalThickness); + loader.close(); + } + + private static void reset(List target, Block[] values) { + target.clear(); + for (Block value : values) target.add(value); + } + } + + private static final class PublishedSampler { + private final Object delegate; + private final Method getStoneAt; + private PublishedSampler(Object delegate, Method getStoneAt) { + this.delegate = delegate; + this.getStoneAt = getStoneAt; + } + Block getStoneAt(int x, int y, int z) throws Exception { + return (Block) getStoneAt.invoke(delegate, x, y, z); + } + } +} diff --git a/src/test/resources/log4j2-test.xml b/src/test/resources/log4j2-test.xml new file mode 100644 index 00000000..9f076c67 --- /dev/null +++ b/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + From f51ce5b0298b247936c4f1a8b803bedacdf3b4cd Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 13 Aug 2026 23:21:59 +0100 Subject: [PATCH 3/3] Document OreSpawn versioning convention --- docs/AGENTS.md | 1 + docs/VERSIONS.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 docs/VERSIONS.md diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 5c7486a7..69525f14 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -12,4 +12,5 @@ Use the focused guides for implementation details: - [BIOMES.md](BIOMES.md) and [DIMENSIONS.md](DIMENSIONS.md) for world integration; - [TEMPLATES.md](TEMPLATES.md) for selectable world styles; - [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; +- [VERSIONS.md](VERSIONS.md) for the shared mod versioning and branch-release convention; - [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md new file mode 100644 index 00000000..83a479c4 --- /dev/null +++ b/docs/VERSIONS.md @@ -0,0 +1,109 @@ +# Mod Versioning Policy + +This document defines how versions are assigned to MMD mods. +It separates the version of the mod from the Minecraft version that the mod supports. + +## Version format + +Mod versions use three numbers: + +```text +Major.Minor.Bug +``` + +For example, OreSpawn `4.0.6` means major version 4, minor version 0, and bug revision 6. + +The final artifact or release may also identify its Minecraft version, such as `OreSpawn-26.2-4.0.6`. The Minecraft version is a compatibility target; it is not part of the mod's `Major.Minor.Bug` progression. + +When the Major or Minor component increases, the components to its right reset to zero. For example: + +```text +4.0.6 -> 4.1.0 +4.1.3 -> 5.0.0 +``` + +## Major version + +Increase the **Major** number for a large-scale change, paradigm shift, or breaking change that moves the mod forward in a fundamental way. + +Examples include: +- Mineralogy 6 no longer containing its own world generation engine, unlike Mineralogy 5. +- OreSpawn 4 gaining a complete terrain generation engine, including strata, unlike OreSpawn 3. + +Compatibility adaptations required to support another Minecraft or loader version do not by themselves require a major version increase when the mod's supported behaviour and public contracts remain equivalent. + +## Minor version + +Increase the **Minor** number for a new feature or a significant change to existing behaviour that does not justify a new major generation. + +Examples include: +- adding a new player-usable block or other substantial feature; +- substantially overhauling a world-generation engine; +- making a significant fix or adjustment that materially changes how a major part of the mod behaves. + +## Bug version + +Increase the **Bug** number for a bug fix or a very small feature that does not materially change the mod's design. + +Examples include: +- correcting a generation defect; +- fixing a user interface or compatibility problem; +- adding or correcting a language file translation; +- making a small documentation or configuration improvement that warrants a + release. + +This component is sometimes called the patch number in other semantic version systems. MMD uses the name **Bug** to make its intended purpose explicit. + +## Ports to new Minecraft versions + +Porting a mod to a new Minecraft version does not automatically change the mod version. If the new branch is functionally equivalent to the source branch, both releases use the same mod version. + +For example: + +```text +Minecraft 26.1.2 / OreSpawn 4.0.6 +Minecraft 26.2 / OreSpawn 4.0.6 +``` + +Target MC/Framework specific implementation details may differ internally where Minecraft or its mod loader requires them. Those adaptations do not require a different mod version when users and integrations receive the same supported behaviour. + +If a port also introduces a feature or fix that changes the functional release, the version must be assessed using the Major, Minor, and Bug rules above. + +## Branch-specific fixes and skipped numbers + +Version numbers are allocated across the mod as a whole and must not be reused for unrelated functional change sets on different Minecraft branches. The same number may be shared by functionally equivalent ports, as described above. + +If a released branch receives a bug fix that other branches do not require, only the affected branch is incremented. For example, that branch may move from `4.0.6` to `4.0.7` while unaffected branches remain on `4.0.6`. + +If a different branch later receives a separate fix, it uses the next unused version, such as `4.0.8`, even if the `4.0.7` fix was not applicable to it. A branch may therefore legitimately skip version numbers. + +This provides three useful guarantees: +1. A version number is not used to describe two different functional change sets. +2. A higher version identifies a later change in the mod's release history. +3. It is immediately visible that one branch may contain work not present in an older-numbered branch. + +A higher version on another Minecraft branch does **not** necessarily mean it contains every lower numbered branch specific fix. Some fixes are relevant only to a particular Minecraft or loader implementation. + +## Release and pull-request documentation + +Because maintained branches can legitimately contain different fixes, the version number alone is not a substitute for release notes. + +Every release and pull request should state: + +- the Minecraft version and loader it targets; +- the mod version before and after the change; +- the features and fixes actually included; +- any fixes from nearby versions that are not applicable to that branch; +- whether the change is functionally equivalent to another maintained branch; +- any migration, compatibility, or configuration considerations for users. + +## Decision summary + +When assigning a version, ask the following questions in order: + +1. Is this a fundamental or breaking new generation of the mod? Increase **Major**. +2. Is this a substantial feature or significant behavioural overhaul? Increase **Minor**. +3. Is this a bug fix or very small feature? Increase **Bug**, using the next unused number across the mod. +4. Is this only a functionally equivalent Minecraft or loader port? Keep the existing mod version. + +The objective is to make versions useful to players, pack developers, mod integrators, and release automation while allowing each maintained Minecraft branch to receive only the changes it actually needs.