diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 972f6e97..f4a94186 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.4 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 cc2eb82e..c6aeeaee 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/build.gradle b/build.gradle index 5cf9337d..d8b49ff4 100644 --- a/build.gradle +++ b/build.gradle @@ -303,6 +303,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') @@ -357,8 +519,16 @@ tasks.configureEach { } else if (name == 'runSurfaceIntegrationReload') { dependsOn 'runSurfaceIntegrationFresh' } + if (name == 'runSurfaceIntegrationFresh' || name == 'runSurfaceIntegrationReload') { + doFirst { + ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(workingDir) + } + doLast { + assertRuntimeLogsClean(workingDir, "Forge 50 ${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/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/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/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. 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 7d3f7d90..7131a6d1 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -77,6 +77,7 @@ public final class SurfaceProbeTestMod { private static final ResourceLocation BIOME_A = ResourceLocation.parse(MODID + ":surface_a"); private static final ResourceLocation BIOME_B = ResourceLocation.parse(MODID + ":surface_b"); private static final ResourceLocation PROBE_GEOME = ResourceLocation.parse(MODID + ":dynamic_biome_geome"); + private static final ResourceLocation DYNAMIC_FLUID = ResourceLocation.parse(MODID + ":fluid/dynamic_water"); private static final ResourceLocation[] BUILT_IN_GEOMES = { ResourceLocation.parse("orespawn:stable_craton"), ResourceLocation.parse("orespawn:mountain_belt"), ResourceLocation.parse("orespawn:volcanic_arc"), ResourceLocation.parse("orespawn:sedimentary_basin"), @@ -108,8 +109,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(ResourceLocation.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"); } @@ -145,6 +158,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(); @@ -348,8 +362,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 50 dynamic fluid deposit produced no covered flowing-water blocks"); + } + return water; } private static int auditSentinels(ServerLevel level, LevelChunk chunk, @@ -463,6 +500,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; } @@ -535,7 +573,7 @@ private static boolean prepareTerrain(WorldGenLevel world, ChunkAccess chunk) { chunk.setBlockState(pos.set(x, groundY - depth, z), Blocks.DIRT.defaultBlockState(), false); } 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(), false); } } @@ -608,5 +646,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 6e015f42..4baec932 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.ResourceLocation; 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) { @@ -82,8 +111,12 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer for (; y >= chunk.getMinBuildHeight(); y--) { cursor.set(x, y, z); - if (terrain.isReplaceable(chunk.getBlockState(cursor))) { - chunk.setBlockState(cursor, pickReplacement(baseRockVal, geomeBase, y), false); + BlockState current = chunk.getBlockState(cursor); + if (terrain.isReplaceable(current) + || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) { + BlockState replacement = pickReplacement(baseRockVal, geomeBase, y); + if (current.equals(replacement)) continue; + chunk.setBlockState(cursor, replacement, false); changed = true; } } @@ -131,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.defaultBlockState()); + } 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..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; @@ -94,6 +95,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 +356,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..3834cdcf --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -0,0 +1,630 @@ +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.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.resources.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())) { + CompoundTag root = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); + 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(CompoundTag root, String sourceFile) { + for (ModListPath path : MOD_LIST_PATHS) { + CompoundTag container = root.getCompound(path.compound); + ListTag mods = container.getList(path.list, 10); + for (int i = 0; i < mods.size(); i++) { + CompoundTag 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 fc8b0850..a61cd0b1 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -88,7 +88,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; } @@ -130,8 +132,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 2fd88c30..3fc6ab61 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 384b2fb3..8e849305 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..20d78920 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 @@ -213,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/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 bea0efdb..a3092c2e 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java @@ -313,6 +313,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); 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 @@ + + + + + + + + + + + + +