diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 397b2bd..7090b63 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,8 @@ 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. * Port OreSpawn 4.0.6 to Minecraft 1.15.2 and Forge 31.2.57 using Java 8. * 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. diff --git a/README.md b/README.md index d12cbae..5d57ab6 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,20 @@ 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. +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/build.gradle b/build.gradle index e658147..414fafd 100644 --- a/build.gradle +++ b/build.gradle @@ -200,6 +200,137 @@ javadoc { test { 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 115/MinecraftMineralogy/build/libs/Mineralogy-1.15.2-5.1.1.jar') + if (mineralogy5Oracle.isFile()) { + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5Oracle.absolutePath + } +} + +// 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 acceptedForge31LogNoise = [ + ~/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 = acceptedForge31LogNoise.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 = file("${buildDir}/surface-integration-fixture/classes") @@ -291,8 +422,14 @@ surfaceIntegrationFreshProcess.doLast { if (!marker.isFile()) { throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration fresh phase', [] as Set) } def surfaceIntegrationReloadProcess = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) +surfaceIntegrationReloadProcess.doLast { + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration reload phase', [] as Set) +} task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { group = 'verification' @@ -327,6 +464,19 @@ task syncForge31EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { " \r\n" + " \r\n" + " \r\n" + ['Client', 'Server', 'Data'].each { String runName -> + File launch = file("run${runName}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + if (!text.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + text = text.replace('', + ' \r\n' + + '') + } + launch.setText(text, 'UTF-8') + } ['Fresh', 'Reload'].each { String phase -> File launch = file("runSurfaceIntegration${phase}.launch") if (!launch.isFile()) { @@ -349,6 +499,22 @@ task syncForge31EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { } } +task verifyForge31EclipseLaunchIsolation { + group = 'verification' + doLast { + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { String launchName -> + File launch = file(launchName) + String launchText = launch.getText('UTF-8') + if (launchText.contains('Mineralogy-') || launchText.contains('biomeIntegrationTest') || + !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") + } + } + } +} + +syncForge31EclipseLaunches.finalizedBy verifyForge31EclipseLaunchIsolation + tasks.matching { it.name == 'genEclipseRuns' }.all { finalizedBy syncForge31EclipseLaunches } diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 5c7486a..69525f1 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/CONFIGURATION.md b/docs/CONFIGURATION.md index c197da2..8b1fda8 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 adc2d70..65ee2ea 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 6cd0843..8ef539a 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -94,3 +94,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/docs/VERSIONS.md b/docs/VERSIONS.md new file mode 100644 index 0000000..83a479c --- /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. 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 d28565c..7db3412 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -107,6 +107,7 @@ public final class SurfaceProbeTestMod { private static final ResourceLocation BIOME_A = new ResourceLocation(MODID + ":surface_a"); private static final ResourceLocation BIOME_B = new ResourceLocation(MODID + ":surface_b"); private static final ResourceLocation PROBE_GEOME = new ResourceLocation(MODID + ":dynamic_biome_geome"); + private static final ResourceLocation DYNAMIC_FLUID = new ResourceLocation(MODID + ":fluid/dynamic_water"); private static final ResourceLocation[] BUILT_IN_GEOMES = { new ResourceLocation("orespawn:stable_craton"), new ResourceLocation("orespawn:mountain_belt"), new ResourceLocation("orespawn:volcanic_arc"), new ResourceLocation("orespawn:sedimentary_basin"), @@ -179,6 +180,16 @@ private static void addUnique(java.util.List> features, 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.DIORITE)))); addPalette(provider, "open_palette_0", OPEN_ID, false); addPalette(provider, "roofed_palette_0", ROOFED_ID, true); provider.dimensionMaterials(new ResourceLocation(MODID + ":materials/nether"), ROOFED_ID, @@ -218,6 +229,7 @@ private void enableGeologyProbe(FMLServerAboutToStartEvent 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(); @@ -437,21 +449,21 @@ private static AuditResult auditDimension(ServerWorld level, boolean roofed) { + ", sentinels=" + sentinels + ", geology=" + geology + ", ceiling=" + ceiling + ", roofTop=" + roofTop); } - long aquiferFluid = 0L; + long aquiferFluid = roofed ? 0L : auditDynamicFluid(level); return new AuditResult(top, underwater, filler, geology, ceiling, roofTop, biomeA, biomeB, edgeChanges, sentinels, aquiferFluid); } - private static long auditDefaultFluid(ServerWorld level) { + private static long auditDynamicFluid(ServerWorld level) { BlockPos.Mutable pos = new BlockPos.Mutable(); long water = 0L; for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { - for (int chunkX = FLUID_PROBE_MIN_CHUNK_X; chunkX <= FLUID_PROBE_MAX_CHUNK_X; chunkX++) { + for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, true); Chunk chunk = level.getChunk(chunkX, chunkZ); for (int x = chunk.getPos().getXStart(); x <= chunk.getPos().getXEnd(); x++) { for (int z = chunk.getPos().getZStart(); z <= chunk.getPos().getZEnd(); z++) { - for (int y = 0; y <= 63; y++) { + for (int y = 12; y <= 30; y++) { if (chunk.getBlockState(pos.setPos(x, y, z)).getBlock() == Blocks.WATER) water++; } } @@ -459,7 +471,7 @@ private static long auditDefaultFluid(ServerWorld level) { } } if (water == 0L) { - throw new IllegalStateException("Minecraft 1.17.1 default-fluid override produced no water in the fixed untouched Nether probe strip"); + throw new IllegalStateException("Forge 31 dynamic fluid deposit produced no covered flowing-water blocks"); } return water; } @@ -649,7 +661,7 @@ private static boolean prepareTerrain(IWorld world, IChunk chunk) { chunk.setBlockState(pos.setPos(x, groundY - depth, z), Blocks.DIRT.getDefaultState(), false); } if (!roofed) { - for (int depth = 6; depth <= 8; depth++) { + for (int depth = 6; depth <= 60 && groundY - depth >= 1; depth++) { chunk.setBlockState(pos.setPos(x, groundY - depth, z), Blocks.END_STONE.getDefaultState(), false); } } 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 f142e3c..efb1760 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; @@ -14,8 +16,13 @@ import net.minecraft.world.IWorld; import net.minecraft.util.ResourceLocation; import net.minecraft.world.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; @@ -23,10 +30,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); @@ -39,9 +68,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) { @@ -82,7 +111,8 @@ public void replaceStoneInChunk(IWorld world, IChunk chunk, BakedTerrainDimensio for (; y >= 0; y--) { cursor.setPos(x, y, z); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current)) { + if (terrain.isReplaceable(current) + || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) { BlockState replacement = pickReplacement(baseRockVal, geomeBase, y); if (!GeomeGeology.changes(current, replacement)) continue; chunk.setBlockState(cursor, replacement, false); @@ -133,4 +163,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 = new ResourceLocation(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 ca3c22a..f425702 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -97,6 +97,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; } @@ -356,6 +357,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 0000000..5ab214d --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -0,0 +1,624 @@ +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) { + 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 = new ResourceLocation(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(new ResourceLocation(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 671364e..81a7106 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -60,7 +60,9 @@ boolean place(FeaturePlaceContext context) { ResourceLocation dimension = WorldIds.dimension(world); 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; } @@ -91,8 +93,7 @@ private CachedGeology geology(ResourceLocation dimension, long seed, 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 ea34110..c326da3 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -2,6 +2,9 @@ import zone.moddev.mc.orespawn.util.JsonCopies; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Locale; import java.util.Optional; @@ -277,6 +280,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 29ecdb3..4793790 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -24,6 +24,7 @@ import net.minecraft.server.MinecraftServer; import net.minecraftforge.fml.event.server.FMLServerAboutToStartEvent; import net.minecraftforge.fml.event.server.FMLServerStoppedEvent; +import net.minecraftforge.fml.loading.FMLPaths; import net.minecraftforge.event.world.WorldEvent; import net.minecraft.world.server.ServerWorld; @@ -133,15 +134,22 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent 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 42b02e8..52eef7b 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java @@ -58,6 +58,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/LegacyMineralogyGeologyParityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java new file mode 100644 index 0000000..e5fa99b --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -0,0 +1,177 @@ +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.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.util.registry.Bootstrap; +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() { + Bootstrap.register(); + } + + @Test + void cyanoSamplerMatchesPublishedMineralogy501AndSealedVectors() 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.0.1 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.0.1 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].getDefaultState(); + 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/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java new file mode 100644 index 0000000..1c4d332 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java @@ -0,0 +1,337 @@ +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.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.nbt.CompressedStreamTools; +import net.minecraft.nbt.CompoundNBT; +import net.minecraft.nbt.ListNBT; +import net.minecraft.util.registry.Bootstrap; + +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() { + Bootstrap.register(); + } + + @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 publishedMineralogy501TomlPreservesEngineNumbersAndAllLists(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + 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.3.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 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.getDefaultState() }); + 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.getDefaultState() }; + 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 { + CompoundNBT root = new CompoundNBT(); + CompoundNBT fml = new CompoundNBT(); + ListNBT mods = new ListNBT(); + CompoundNBT mod = new CompoundNBT(); + 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())) { + CompressedStreamTools.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/resources/log4j2-test.xml b/src/test/resources/log4j2-test.xml new file mode 100644 index 0000000..9f076c6 --- /dev/null +++ b/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + +