diff --git a/CHANGELOG.txt b/CHANGELOG.txt index e65e055..c31462d 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,10 @@ -Version 4.0.6 +Version 4.0.6.117011 -* Port OreSpawn 4.0.6 to Minecraft 1.17.1 and Forge 37.1.1 using Java 16 +* Adopt target-qualified four-component versions so Minecraft and loader compatibility can be identified from the mod version. +* Preserve generated worlds made with Mineralogy 1.10, 1.12, or 5.x by reading their saved mod metadata and exact legacy configuration before creating the OreSpawn world profile. +* Write human-readable, idempotent upgrade reports for legacy OreSpawn and Mineralogy imports while retaining source files and existing chunks unchanged. +* Audit automated runtime logs and dynamic-fluid generation so logged worldgen failures cannot pass merely because the process exits normally. +* Port OreSpawn 4.0.6.117011 to Minecraft 1.17.1 and Forge 37.1.1 using Java 16. * 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 077d600..fb199ab 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,20 @@ Important files: | `/serverconfig/orespawn-worldgen.json` | Complete settings snapshot for one world | | `config/-orespawn.json` | Optional modpack override for one provider | | `config/orespawn-guide/README.md` | Guide exported automatically on first load | +| `config/orespawn-upgrade-report.txt` | Human summary produced when legacy OreSpawn rules are imported | +| `/serverconfig/orespawn-upgrade-report.txt` | Human summary produced when a generated legacy Mineralogy world is pinned to its saved settings | Profile edits affect newly generated chunks. Ore and flat-bedrock retrogen are separate opt-in features; OreSpawn never retro-generates rock strata. +When an already-generated world has saved Mineralogy 1.10, 1.12, or 5.x mod +metadata but no OreSpawn world profile, OreSpawn reads the matching published +configuration contract and records the exact engine, numeric settings, rock +order, and white/blacklists in the new world profile. Saved-world identity +wins over stale files in the installation. A fresh world is never reclassified +merely because an old `mineralogy.cfg` or `mineralogy-common.toml` remains in +the instance. + To move a configured single-player world to a dedicated server, copy the world's `serverconfig/orespawn-worldgen.json` with the world and install the same provider mods on the server. diff --git a/build.gradle b/build.gradle index 7728977..e15d4e5 100644 --- a/build.gradle +++ b/build.gradle @@ -299,6 +299,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 117/MinecraftMineralogy/build/libs/Mineralogy-1.17.1-5.3.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 acceptedForge37LogNoise = [ + ~/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 = acceptedForge37LogNoise.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') @@ -360,14 +522,25 @@ def surfaceIntegrationProcess = { String phase, dependency -> group = 'verification' description = "Runs the ${phase.toLowerCase(Locale.ROOT)} surface-probe server behind a process boundary." dependsOn dependency, "prepareRunSurfaceIntegration${phase}" - doFirst { - JavaExec runTask = tasks.getByName("runSurfaceIntegration${phase}") as JavaExec - File argumentFile = layout.buildDirectory.file( - "surface-integration-fixture/${phase.toLowerCase(Locale.ROOT)}.args").get().asFile - argumentFile.parentFile.mkdirs() - List arguments = [] - arguments.addAll(runTask.allJvmArgs) - arguments.add('-cp') + doFirst { + JavaExec runTask = tasks.getByName("runSurfaceIntegration${phase}") as JavaExec + File argumentFile = layout.buildDirectory.file( + "surface-integration-fixture/${phase.toLowerCase(Locale.ROOT)}.args").get().asFile + argumentFile.parentFile.mkdirs() + List arguments = [] + arguments.addAll(runTask.allJvmArgs) + String legacyClassPathArgument = arguments.find { + it.startsWith('-DlegacyClassPath.file=') + } + if (legacyClassPathArgument != null) { + File legacyClassPath = file(legacyClassPathArgument.substring( + '-DlegacyClassPath.file='.length())) + legacyClassPath.parentFile.mkdirs() + legacyClassPath.setText(runTask.classpath.files.findAll { it.isFile() } + .collect { it.absolutePath } + .join(System.lineSeparator()) + System.lineSeparator(), 'UTF-8') + } + arguments.add('-cp') arguments.add(runTask.classpath.asPath) arguments.add(runTask.main) arguments.addAll(runTask.args) @@ -393,7 +566,24 @@ def surfaceIntegrationProcess = { String phase, dependency -> } def surfaceIntegrationFreshProcess = surfaceIntegrationProcess('Fresh', prepareSurfaceIntegrationTest) +surfaceIntegrationFreshProcess.configure { + doLast { + File marker = surfaceIntegrationRunDirectory.get().file( + 'surface-integration-world/surfaceprobe-integration.properties').asFile + if (!marker.isFile()) { + throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") + } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory.get().asFile, + 'Forge 37 surface integration fresh phase', [] as Set) + } +} def surfaceIntegrationReloadProcess = surfaceIntegrationProcess('Reload', surfaceIntegrationFreshProcess) +surfaceIntegrationReloadProcess.configure { + doLast { + assertRuntimeLogsClean(surfaceIntegrationRunDirectory.get().asFile, + 'Forge 37 surface integration reload phase', [] as Set) + } +} def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { group = 'verification' diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 5c7486a..04e7b78 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 four-component target-qualified 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 6a61fdb..e177e48 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 943de06..eb248b2 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 f18e6e7..08a587f 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 0000000..005f28d --- /dev/null +++ b/docs/VERSIONS.md @@ -0,0 +1,201 @@ +# Mod Versioning Policy + +This document defines how versions are assigned to MMD mods and how an exact +Minecraft and loader target is encoded in a release version. + +## Version format + +Mod versions use four numeric components: + +```text +Major.Minor.Bug.Target +``` + +The first three components describe the functional release. For example, +OreSpawn `4.0.6` means major version 4, minor version 0, and bug revision 6. + +The fourth component identifies the Minecraft and loader target. A complete +release version such as `4.0.6.120061` therefore identifies both the OreSpawn +4.0.6 feature set and its Minecraft 1.20.6 Forge build. + +This is an expanded, Maven-compatible versioning convention. It is not strict +Semantic Versioning 2.0, which defines exactly three numeric core components. + +When the Major or Minor component increases, the functional components to its +right reset to zero. The Target component is then appended for the build being +released. For example: + +```text +4.0.6.120061 -> 4.1.0.120061 +4.1.3.120061 -> 5.0.0.120061 +``` + +## Target component + +The Target component is deterministic and is not another feature or bug +sequence number. + +To calculate it: + +1. Normalize the Minecraft version to `major.minor.patch`, using zero when the + patch component is omitted. +2. Concatenate the Minecraft major number without padding, the minor number as + two digits, the patch number as two digits, and the one-digit loader code. +3. Use loader code `1` for Forge and `2` for NeoForge. + +The component can be decoded from right to left: one loader digit, two patch +digits, two minor digits, and all remaining digits for the Minecraft major +version. + +Examples: + +| Minecraft | Loader | Target | Full OreSpawn 4.0.6 version | +| --- | --- | ---: | --- | +| 1.13.2 | Forge | `113021` | `4.0.6.113021` | +| 1.20.6 | Forge | `120061` | `4.0.6.120061` | +| 1.21.11 | Forge | `121111` | `4.0.6.121111` | +| 26.1.2 | Forge | `2601021` | `4.0.6.2601021` | +| 26.2 | Forge | `2602001` | `4.0.6.2602001` | +| 26.2 | NeoForge | `2602002` | `4.0.6.2602002` | + +Historical MMD releases may also have four numeric components but may have used +the fourth component differently. This policy applies prospectively; it does +not reinterpret an old release number. + +## 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 versioning +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 +functional `Major.Minor.Bug` version. Functionally equivalent ports share those +first three components, while their complete versions have different Target +components. + +For example: + +```text +Minecraft 26.1.2 / Forge / OreSpawn 4.0.6.2601021 +Minecraft 26.2 / Forge / OreSpawn 4.0.6.2602001 +Minecraft 26.2 / NeoForge / OreSpawn 4.0.6.2602002 +``` + +Target-specific implementation details may differ internally where Minecraft +or its mod loader requires them. Those adaptations do not change the functional +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 first three components must be assessed using the Major, Minor, and Bug +rules above. The Target component always identifies the build's actual +Minecraft and loader target. + +## Branch-specific fixes and skipped numbers + +Functional version numbers are allocated across the mod as a whole and must +not be reused for unrelated change sets on different Minecraft branches. The +same `Major.Minor.Bug` may be shared by functionally equivalent ports. + +If a released branch receives a bug fix that other branches do not require, +only the affected branch's Bug number is incremented. For example, Forge +1.13.2 may move from `4.0.6.113021` to `4.0.7.113021` while unaffected branches +remain on their target-qualified 4.0.6 versions. + +If a different branch later receives a separate fix, it uses the next unused +Bug number, such as `4.0.8`, even if the `4.0.7` fix was not applicable to it. +A branch may therefore legitimately skip functional version numbers. + +This provides three useful guarantees: + +1. A functional version is not used to describe two unrelated change sets. +2. A higher functional version identifies a later change in the mod's release + history. +3. The Target component identifies the exact Minecraft and loader build without + overloading the functional version. + +A higher functional 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. + +## Dependency ranges + +Dependencies should normally express the compatible functional release range. +For example, Maven-style range `[4.0.6,5.0.0)` deliberately accepts all +target-qualified OreSpawn 4.0.6 builds while excluding OreSpawn 5. + +Consumers must still declare their supported Minecraft version and loader in +their own metadata. The Target component makes that compatibility visible; it +does not replace loader-level compatibility checks. + +## 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 complete four-component version and its functional `Major.Minor.Bug`; +- 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 `Major.Minor.Bug`. +5. Calculate and append the Target component for the exact Minecraft and loader + build. + +The objective is to make versions useful to players, pack developers, mod +integrators, release automation, and support teams while allowing each +maintained Minecraft branch to receive only the changes it actually needs. diff --git a/gradle.properties b/gradle.properties index 08bb907..351cb8f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -48,7 +48,7 @@ mod_name=MMD OreSpawn # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-2.1 # The mod version. See https://semver.org/ -mod_version=4.0.6 +mod_version=4.0.6.117011 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html 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 26280c5..b83fbff 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -98,6 +98,7 @@ public final class SurfaceProbeTestMod { private static final ResourceLocation BIOME_A = new ResourceLocation(MODID + ":surface_a"); private static final ResourceLocation BIOME_B = new ResourceLocation(MODID + ":surface_b"); private static final ResourceLocation PROBE_GEOME = new ResourceLocation(MODID + ":dynamic_biome_geome"); + private static final ResourceLocation DYNAMIC_FLUID = new ResourceLocation(MODID + ":fluid/dynamic_water"); private static final ResourceLocation[] BUILT_IN_GEOMES = { new ResourceLocation("orespawn:stable_craton"), new ResourceLocation("orespawn:mountain_belt"), new ResourceLocation("orespawn:volcanic_arc"), new ResourceLocation("orespawn:sedimentary_basin"), @@ -171,6 +172,16 @@ private static void addUnique(java.util.List>> 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_0", OPEN_ID, false); addPalette(provider, "roofed_palette_0", ROOFED_ID, true); provider.dimensionMaterials(new ResourceLocation(MODID + ":materials/nether"), ROOFED_ID, @@ -210,6 +221,7 @@ private void enableGeologyProbe(FMLServerAboutToStartEvent event) { throw new IllegalStateException("Could not read the test-owned End geology profile", exception); } try { + root.addProperty("place_fluid_deposits", true); JsonObject terrain = root.getAsJsonObject("terrain_dimensions"); if (terrain == null) { terrain = new JsonObject(); @@ -423,21 +435,21 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { + ", sentinels=" + sentinels + ", geology=" + geology + ", ceiling=" + ceiling + ", roofTop=" + roofTop); } - long aquiferFluid = roofed ? auditDefaultFluid(level) : 0L; + long aquiferFluid = roofed ? 0L : auditDynamicFluid(level); return new AuditResult(top, underwater, filler, geology, ceiling, roofTop, biomeA, biomeB, edgeChanges, sentinels, aquiferFluid); } - private static long auditDefaultFluid(ServerLevel level) { + 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 = FLUID_PROBE_MIN_CHUNK_X; chunkX <= FLUID_PROBE_MAX_CHUNK_X; chunkX++) { + for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, true); 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 = level.getMinBuildHeight(); y <= Math.min(63, level.getMaxBuildHeight() - 1); y++) { + for (int y = 12; y <= 30; y++) { if (chunk.getBlockState(pos.set(x, y, z)).is(Blocks.WATER)) water++; } } @@ -445,7 +457,7 @@ private static long auditDefaultFluid(ServerLevel level) { } } if (water == 0L) { - throw new IllegalStateException("Minecraft 1.17.1 default-fluid override produced no water in the fixed untouched Nether probe strip"); + throw new IllegalStateException("Forge 37 dynamic fluid deposit produced no covered flowing-water blocks"); } return water; } @@ -637,7 +649,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); } } 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 e7b720a..07eac42 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.Registry; 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) { @@ -83,8 +112,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; } } @@ -132,4 +165,29 @@ private BlockState pickStateFromList(int value, BlockState[] list) { return list[whiteNoiseArray[(value / layerThickness) & 0xFF] % list.length]; } + static BlockState[] resolveRockOrder(WorldGeologyProfile profile, String key, + BlockState[] fallback) { + if (!profile.hasCyanoRockOrder(key)) return fallback; + List states = new ArrayList<>(); + for (String idText : profile.cyanoRockOrder(key)) { + try { + ResourceLocation id = new ResourceLocation(idText); + Block block = ForgeRegistries.BLOCKS.getValue(id); + 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 cb2c848..b264934 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -12,6 +12,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; @@ -96,6 +97,7 @@ static JsonObject migrateIfNeeded(Path target, JsonObject defaults, report.add("Original files were retained unchanged. Review registry IDs and biome/dimension warnings before deleting them."); if (!write(target, migrated)) return null; writeReport(config, report); + writeUpgradeReport(config, imported, report); LOGGER.info("Migrated {} legacy OreSpawn definitions into '{}'", imported, target); return migrated; } @@ -356,6 +358,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.117011 Upgrade Report"); + lines.add("================================"); + lines.add(""); + lines.add("RESULT: Legacy OreSpawn settings were imported into the OS4 profile."); + lines.add("- Spawn definitions imported: " + imported); + lines.add("- Detailed translation report: " + + config.resolve("orespawn-migration/migration-report.txt").toAbsolutePath()); + for (String entry : detail) { + if (entry.startsWith("Warning:") || entry.startsWith("Skipped") + || entry.startsWith("Clamped")) lines.add("- " + entry); + } + lines.add(""); + lines.add("Original legacy configuration files were retained unchanged."); + writeTextAtomically(config.resolve("orespawn-upgrade-report.txt"), lines); + } + + private static void writeTextAtomically(Path path, List lines) { + Path temporary = path.resolveSibling(path.getFileName().toString() + ".tmp"); + try { + Files.createDirectories(path.getParent()); + byte[] bytes = (String.join(System.lineSeparator(), lines) + System.lineSeparator()) + .getBytes(StandardCharsets.UTF_8); + if (Files.isRegularFile(path) && Arrays.equals(Files.readAllBytes(path), bytes)) return; + Files.write(temporary, bytes); + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + try { Files.deleteIfExists(temporary); } catch (IOException ignored) { } + LOGGER.warn("Could not write OreSpawn upgrade report '{}'", path, e); + } + } + private static JsonObject object(JsonObject root, String key) { if (!root.has(key) || !root.get(key).isJsonObject()) root.add(key, new JsonObject()); return root.getAsJsonObject(key); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java new file mode 100644 index 0000000..89364fb --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -0,0 +1,624 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +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) { + 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.117011 Upgrade Report"); + lines.add("================================"); + lines.add(""); + lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); + lines.add(enabled + ? "Geology remains on the " + engineLabel(engine) + " using " + + lineage.label + " behavior." + : "Legacy Mineralogy geology was disabled and remains disabled for this world."); + lines.add("This prevents an implicit settings change between old and newly generated chunks."); + lines.add(""); + lines.add("Legacy world detection"); + lines.add("- Saved mod metadata: " + identity.sourceFile); + lines.add("- Saved Mineralogy version: " + identity.version); + lines.add("- Selected config lineage: " + lineage.label); + lines.add("- Selected engine: " + engine.name()); + lines.add("- Hybrid 1.10/1.12 keys found: " + hybridConfig); + lines.add(""); + lines.add("Legacy Mineralogy configuration"); + lines.add("- Source: " + configPath.toAbsolutePath()); + lines.add("- Source file found: " + (configFound ? "yes" + : "no; published " + lineage.label + " defaults used")); + lines.add("- Geology enabled: " + enabled); + lines.add("- Geome size: " + geomeSize); + lines.add("- Rock layer noise: " + rockLayerNoise); + lines.add("- Rock layer thickness: " + layerThickness); + lines.add("- Realistic coal layers: " + realisticCoal + + (lineage == Lineage.MINERALOGY_110 ? "" : " (not used by this lineage)")); + for (String key : LIST_KEYS) { + lines.add("- " + key + " (" + values.list(key).size() + "): " + + String.join(", ", values.list(key))); + } + lines.add(""); + lines.add("Effective rock outputs"); + lines.add("- Igneous order (" + igneous.size() + "): " + String.join(", ", igneous)); + lines.add("- Metamorphic order (" + metamorphic.size() + "): " + + String.join(", ", metamorphic)); + lines.add("- Sedimentary order (" + sedimentary.size() + "): " + + String.join(", ", sedimentary)); + lines.add(""); + if (missing.isEmpty() && warnings.isEmpty()) { + lines.add("WARNINGS: None. Every preserved rock ID is registered."); + } else { + lines.add("WARNINGS:"); + for (String warning : warnings) lines.add("- " + warning); + for (String id : missing) lines.add("- Rock ID is not currently registered: " + id); + } + lines.add(""); + lines.add("OreSpawn did not rewrite the source Mineralogy configuration or existing chunks."); + lines.add("The generated OreSpawn world profile and this report are written atomically and are byte-stable on reload."); + lines.add("To change this world's geology later, make that choice explicitly and expect a generation seam."); + writeTextAtomically(report, lines); + } + + private static String engineLabel(GeologyMode engine) { + return engine == GeologyMode.LEGACY ? "Cyano layer engine" : "Mineralogy geome engine"; + } + + @SafeVarargs + private static List missingBlocks(List... families) { + Set missing = new LinkedHashSet<>(); + for (List family : families) { + for (String idText : family) { + try { + ResourceLocation id = new ResourceLocation(idText); + if (!ForgeRegistries.BLOCKS.containsKey(id)) missing.add(id.toString()); + } catch (RuntimeException e) { + missing.add(idText + " (invalid registry name)"); + } + } + } + return new ArrayList<>(missing); + } + + private static void writeTextAtomically(Path report, List lines) { + Path temporary = report.resolveSibling(report.getFileName().toString() + ".tmp"); + try { + Files.createDirectories(report.getParent()); + byte[] data = (String.join(System.lineSeparator(), lines) + System.lineSeparator()) + .getBytes(StandardCharsets.UTF_8); + if (Files.isRegularFile(report) && Arrays.equals(Files.readAllBytes(report), data)) return; + Files.write(temporary, data); + try { + Files.move(temporary, report, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, report, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + try { Files.deleteIfExists(temporary); } catch (IOException ignored) { } + LOGGER.warn("Could not write legacy Mineralogy upgrade report '{}'", report, e); + } + } + + private static boolean hasGeneratedOverworldChunks(Path worldRoot) { + Path regions = worldRoot.resolve("region"); + if (!Files.isDirectory(regions)) return false; + try (DirectoryStream files = Files.newDirectoryStream(regions, "r.*.*.mca")) { + return files.iterator().hasNext(); + } catch (IOException e) { + LOGGER.warn("Could not inspect existing world regions in '{}'", regions, e); + return false; + } + } + + private static MineralogyIdentity legacyMineralogyIdentity(Path worldRoot) { + for (String fileName : new String[] { "level.dat", "level.dat_old" }) { + Path levelDat = worldRoot.resolve(fileName); + if (!Files.isRegularFile(levelDat)) continue; + try (FileInputStream input = new FileInputStream(levelDat.toFile())) { + CompoundTag root = NbtIo.readCompressed(input); + MineralogyIdentity identity = identity(root, fileName); + if (identity != null) return identity.legacy ? identity : null; + } catch (IOException | RuntimeException e) { + LOGGER.warn("Could not inspect '{}' for legacy Mineralogy metadata", levelDat, e); + } + } + return null; + } + + private static MineralogyIdentity identity(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(new ResourceLocation(value).toString()); } + catch (RuntimeException e) { + LOGGER.warn("Ignoring invalid legacy Mineralogy rock registry name '{}'", value); + } + } + + private static String unquote(String value) { + String trimmed = value.trim(); + if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.substring(1, trimmed.length() - 1) + .replace("\\\"", "\"").replace("\\\\", "\\"); + } + return trimmed; + } + + private static GeologyMode geologyMode(ConfigValues values, List warnings) { + String configured = values.scalar("geology_mode"); + if (configured == null || configured.trim().isEmpty()) return GeologyMode.GEOME; + try { return GeologyMode.valueOf(configured.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException e) { + warnings.add("Invalid GEOLOGY_MODE '" + configured + "'; published GEOME default used."); + return GeologyMode.GEOME; + } + } + + private static List effectiveList(List defaults, ConfigValues values, + String whitelistKey, String blacklistKey, boolean deduplicateWhitelist) { + List result = new ArrayList<>(defaults); + for (String id : values.list(whitelistKey)) { + if (!deduplicateWhitelist || !result.contains(id)) result.add(id); + } + for (String id : values.list(blacklistKey)) result.remove(id); + return result; + } + + private static int integer(ConfigValues values, String key, int fallback, int min, int max) { + try { + int value = values.scalar(key) == null ? fallback : Integer.parseInt(values.scalar(key)); + return Math.max(min, Math.min(max, value)); + } catch (RuntimeException e) { return fallback; } + } + + private static double decimal(ConfigValues values, String key, + double fallback, double min, double max) { + try { + double value = values.scalar(key) == null ? fallback : Double.parseDouble(values.scalar(key)); + return Math.max(min, Math.min(max, value)); + } catch (RuntimeException e) { return fallback; } + } + + private static boolean bool(ConfigValues values, String key, boolean fallback) { + String value = values.scalar(key); + if (value == null) return fallback; + if ("true".equalsIgnoreCase(value)) return true; + if ("false".equalsIgnoreCase(value)) return false; + return fallback; + } + + private static JsonArray array(List values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String normalizeKey(String value) { + return unquote(value).trim().toLowerCase(Locale.ROOT).replace('-', '_'); + } + + private static boolean isListKey(String key) { + for (String candidate : LIST_KEYS) if (candidate.equals(key)) return true; + return false; + } + + private static String firstNonBlank(String first, String second) { + return first != null && !first.trim().isEmpty() ? first : second == null ? "" : second; + } + + private static List list(String... values) { + return Collections.unmodifiableList(Arrays.asList(values)); + } + + private static final List LIST_KEYS = list( + "igneous_whitelist", "igneous_blacklist", + "metamorphic_whitelist", "metamorphic_blacklist", + "sedimentary_whitelist", "sedimentary_blacklist"); + + private static final List MOD_LIST_PATHS = Arrays.asList( + new ModListPath("fml", "LoadingModList"), + new ModListPath("fml", "ModList"), + new ModListPath("FML", "ModList")); + + private enum Lineage { + MINERALOGY_110("Mineralogy 1.10", IGNEOUS_110, METAMORPHIC_110, + Collections.emptyList()), + MINERALOGY_112("Mineralogy 1.12", IGNEOUS_112, METAMORPHIC_112, SEDIMENTARY_112), + MINERALOGY_5("Mineralogy 5.x", IGNEOUS_5, METAMORPHIC_5, SEDIMENTARY_5); + + final String label; + final List igneous; + final List metamorphic; + final List sedimentary; + + Lineage(String label, List igneous, List metamorphic, + List sedimentary) { + this.label = label; + this.igneous = igneous; + this.metamorphic = metamorphic; + this.sedimentary = sedimentary; + } + + static Lineage forVersion(String version) { + List parts = versionParts(version); + if (!parts.isEmpty() && parts.get(0) == 5) return MINERALOGY_5; + if (parts.size() >= 2 && parts.get(0) == 3 && parts.get(1) <= 3) { + return MINERALOGY_110; + } + return MINERALOGY_112; + } + } + + private static final class ConfigValues { + final Map scalars = new LinkedHashMap<>(); + final Map> lists = new LinkedHashMap<>(); + String scalar(String key) { return scalars.get(normalizeKey(key)); } + List list(String key) { + List value = lists.get(normalizeKey(key)); + return value == null ? Collections.emptyList() : Collections.unmodifiableList(value); + } + void clear() { scalars.clear(); lists.clear(); } + } + + private static final class ModListPath { + final String compound; + final String list; + ModListPath(String compound, String list) { this.compound = compound; this.list = list; } + } + + private static final class MineralogyIdentity { + final String version; + final String sourceFile; + final boolean legacy; + MineralogyIdentity(String version, String sourceFile, boolean legacy) { + this.version = version; + this.sourceFile = sourceFile; + this.legacy = legacy; + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java index b5928fa..12d62a1 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -87,7 +87,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; } @@ -127,8 +129,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 4409f5f..8092fcb 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -2,6 +2,9 @@ import zone.moddev.mc.orespawn.util.JsonCopies; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Locale; import java.util.Optional; @@ -277,6 +280,34 @@ public int cyanoLayerThickness() { return nestedInt("cyano", "rock_layer_thickness", 8, 1, 255); } + public boolean cyanoEnabled() { + return nestedBoolean("cyano", "enabled", true); + } + + public boolean cyanoRealisticCoalLayers() { + return nestedBoolean("cyano", "realistic_coal_layers", false); + } + + public boolean hasLegacyMineralogySnapshot() { + return root.has("cyano") && root.get("cyano").isJsonObject() + && root.getAsJsonObject("cyano").has("migrated_from"); + } + + boolean hasCyanoRockOrder(String key) { + return root.has("cyano") && root.get("cyano").isJsonObject() + && root.getAsJsonObject("cyano").has(key) + && root.getAsJsonObject("cyano").get(key).isJsonArray(); + } + + List cyanoRockOrder(String key) { + if (!hasCyanoRockOrder(key)) return Collections.emptyList(); + List result = new ArrayList<>(); + for (JsonElement element : root.getAsJsonObject("cyano").getAsJsonArray(key)) { + if (element.isJsonPrimitive()) result.add(element.getAsString()); + } + return Collections.unmodifiableList(result); + } + private static JsonObject recommendedFormationJson() { JsonObject formations = new JsonObject(); formations.addProperty("algorithm", Algorithm.STABLE_LAYERS.configName()); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java index 52cdea0..c77caa0 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -25,6 +25,7 @@ import net.minecraft.world.level.storage.LevelResource; import net.minecraftforge.fmlserverevents.FMLServerAboutToStartEvent; import net.minecraftforge.fmlserverevents.FMLServerStoppedEvent; +import net.minecraftforge.fml.loading.FMLPaths; import net.minecraftforge.event.world.WorldEvent; import net.minecraft.server.level.ServerLevel; @@ -134,15 +135,22 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { LOGGER.info("Merged new OreSpawn worldgen-provider definitions into '{}'", profilePath); } } else { - pending = consumePendingProfile(); boolean generatedWorld = hasGeneratedOverworldChunks(worldRoot); + pending = consumePendingProfile(); String source; - if (pending != null) { + if (generatedWorld) { + WorldGeologyProfile legacyMineralogy = LegacyMineralogyProfileMigration.migrateIfNeeded( + worldRoot, FMLPaths.CONFIGDIR.get(), GeomeConfig.globalBaseProfile()); + if (legacyMineralogy != null) { + profile = legacyMineralogy; + source = "legacy Mineralogy settings (existing world)"; + } else { + profile = GeomeConfig.globalBaseProfile(); + source = "instance (existing world)"; + } + } else if (pending != null) { profile = pending; source = "Create World"; - } else if (generatedWorld) { - profile = GeomeConfig.globalBaseProfile(); - source = "instance (existing world)"; } else { profile = fallback.copy(); source = "installed-pack fresh-world"; diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java index 7dc9c87..7cd53b1 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java @@ -59,6 +59,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 @@ -215,4 +218,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 0000000..fbe64df --- /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 cyanoSamplerMatchesPublishedMineralogy530AndSealedVectors() 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.3.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.3.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 0000000..ad6453b --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java @@ -0,0 +1,339 @@ +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 publishedMineralogy530TomlPreservesEngineNumbersAndAllLists(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.3.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.3.0"); + Path config = config(root, "mineralogy-common.toml", + "[options]\nPLACE_MINERALOGY_ROCK = true\n" + + "[world-gen]\nGEOLOGY_MODE = \"LEGACY\"\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(GeologyMode.LEGACY, migrated.geologyMode()); + assertReport(world, "Selected engine: LEGACY", "Cyano layer engine"); + } + + @Test + void malformedMineralogy5ValuesUsePublishedDefaultsWithoutBroadening(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE = \"unknown\"\nGEOME_SIZE = \"bad\"\n" + + "ROCK_LAYER_NOISE = -2\nROCK_LAYER_THICKNESS = 9999\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(GeologyMode.GEOME, migrated.geologyMode()); + assertEquals(100, migrated.cyanoGeomeSize()); + assertEquals(1.0D, migrated.cyanoRockLayerNoise()); + assertEquals(255, migrated.cyanoLayerThickness()); + assertReport(world, "Invalid GEOLOGY_MODE 'unknown'", "published GEOME default used"); + } + + @Test + void savedLineageWinsWhenAnotherStaleConfigFileIsPresent(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy.cfg", + "B:PLACE_MINERALOGY_ROCK=false\nI:GEOME_SIZE=144\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertTrue(migrated.cyanoEnabled()); + assertEquals(100, migrated.cyanoGeomeSize()); + assertReport(world, "Source file found: no; published Mineralogy 5.x defaults used", + "Found mineralogy.cfg but saved world metadata selects Mineralogy 5.x"); + } + + @Test + void freshWorldWithStaleLegacyFilesKeepsCurrentCreateWorldChoice(@TempDir Path root) + throws Exception { + Path fresh = root.resolve("fresh"); + Files.createDirectories(fresh); + writeLevelDat(fresh, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE=\"LEGACY\"\n"); + + assertNull(migrate(fresh, config)); + } + + @Test + void validLevelDatOldIsUsedWhenCurrentMetadataCannotBeRead(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Files.move(world.resolve("level.dat"), world.resolve("level.dat_old")); + Files.write(world.resolve("level.dat"), new byte[] { 1, 2, 3, 4 }); + Path config = config(root, "mineralogy-common.toml", "[world-gen]\nGEOME_SIZE=121\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(121, migrated.cyanoGeomeSize()); + assertReport(world, "Saved mod metadata: level.dat_old (fml/LoadingModList)"); + } + + @Test + void modernMineralogyWorldIsNotReclassifiedByOldFiles(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "6.0.0"); + Path config = config(root, "mineralogy.cfg", "I:GEOME_SIZE=144\n"); + assertNull(migrate(world, config)); + } + + @Test + void migrationAndReportAreByteStableAndSourceRemainsUntouched(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE=\"LEGACY\"\nGEOME_SIZE=111\n"); + Path source = config.resolve("mineralogy-common.toml"); + String sourceHash = sha256(source); + + WorldGeologyProfile first = migrate(world, config); + Path report = world.resolve("serverconfig/orespawn-upgrade-report.txt"); + byte[] reportBytes = Files.readAllBytes(report); + WorldGeologyProfile second = migrate(world, config); + + assertEquals(first.toJson(), second.toJson()); + assertTrue(Arrays.equals(reportBytes, Files.readAllBytes(report))); + assertEquals(sourceHash, sha256(source)); + } + + @Test + void snapshottedOrderPreservesDuplicatesAndFallsBackOnlyForEmptyFamily() { + WorldGeologyProfile base = WorldGeologyProfile.recommended(false); + JsonObject root = base.rootCopy(); + JsonObject cyano = new JsonObject(); + cyano.add("sedimentary_rocks", array( + "minecraft:sandstone", "minecraft:coal_ore", "minecraft:sandstone")); + cyano.addProperty("migrated_from", "test"); + root.add("cyano", cyano); + BlockState[] resolved = Geology.resolveRockOrder(base.withRoot(root), + "sedimentary_rocks", new BlockState[] { Blocks.BEDROCK.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/resources/log4j2-test.xml b/src/test/resources/log4j2-test.xml new file mode 100644 index 0000000..9f076c6 --- /dev/null +++ b/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + +