diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 121739c..ab9a616 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -6,6 +6,11 @@ Version 4.0.6 deduplicated OS4 scheduler with atomic backups and an idempotent report. * Preserve 1.12 metadata block states, legacy dimension/biome selectors, weighted outputs, retrogen flags, vanilla suppression, and flat bedrock. +* Keep existing Mineralogy 3 worlds on the matching Cyano engine contract, + distinguishing carried 1.10 configurations from native 1.12 configurations, + exact rock order, disabled geology, family lists, and realistic coal behavior. +* Write deterministic human-readable upgrade reports for consumed OS3 and + Mineralogy configuration alongside the detailed machine-readable report. * 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. @@ -15,6 +20,8 @@ Version 4.0.6 later ordinary mod listeners cannot re-enable claimed vanilla ore features. * Keep air-exposure inspection inside the active chunk so edge candidates do not load neighbouring chunks or depend on neighbour-generation order. +* Place dynamic fluid deposits through Forge's world-write path so scheduled + liquid ticks cannot retain OreSpawn's reusable generation cursor. * Adapt geology, surfaces, ores, fluids, bedrock and static biome overlays to Forge 1.12 terrain events and one deduplicated IWorldGenerator fallback. * Register the early surface/geology coordinator on Forge 1.12's actual event diff --git a/README.md b/README.md index 3ce0ed0..f6b44fc 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,21 @@ Important files: Profile edits affect newly generated chunks. Ore and flat-bedrock retrogen are separate opt-in features; OreSpawn never retro-generates rock strata. +When an existing world records Mineralogy 3 or earlier and has no OreSpawn 4 +world profile, OreSpawn preserves that world's Cyano geology contract before +new chunks generate. It distinguishes carried Mineralogy 1.10 configuration +from native Mineralogy 1.12 configuration, including the different ordered +rock families, `REALISTIC_COAL_LAYERS`, and `PLACE_MINERALOGY_ROCK`. A hybrid +file created while upgrading is interpreted using the Mineralogy version saved +with the world. Fresh worlds still use the installed provider's recommended +engine; selecting Sky for an upgraded world is an explicit choice which may +create an old/new terrain seam. + +After an upgrade, read `config/orespawn-upgrade-report.txt` for translated OS3 +rules and `/serverconfig/orespawn-upgrade-report.txt` for the Mineralogy +handoff. The reports list the sources, selected lineage, preserved values and +anything needing review without rewriting existing chunks. + 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 4420c74..0c2b1c7 100644 --- a/build.gradle +++ b/build.gradle @@ -158,8 +158,92 @@ javadoc { test { useJUnitPlatform() + // The parity test opens each published engine in its own URLClassLoader. + // Keeping these jars out of Gradle configurations also keeps Forge's + // ordinary Eclipse launch from discovering two Mineralogy mods. + systemProperty 'orespawn.mineralogy110Oracle', + file('../migration-fixtures/sources/artifacts/Mineralogy-1.10.2-3.3.8.26.jar').absolutePath + systemProperty 'orespawn.mineralogy112Oracle', + file('../migration-fixtures/sources/artifacts/Mineralogy-1.12.2-3.8.0.53.jar').absolutePath +} + +// Runtime processes are not green merely because they exit with code zero. +// Forge 14 can log a fatal worldgen/callback error and still shut down cleanly. +def acceptedForge14LogNoise = [ + ~/Apache Maven library folder was not in the format expected/, + ~/\[FML\]: Full: .*maven-artifact-/, + ~/\[FML\]: Trimmed: .*maven-artifact/, + ~/FML appears to be missing any signature data/, + ~/Unable to read a class file correctly/, + ~/There was a problem reading the entry (?:META-INF\/versions\/9\/)?module-info\.class .*probably a corrupt zip/ +] + +def assertRuntimeLogsClean = { File runDirectory, String context -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (crashDirectory.isDirectory()) { + def crashes = fileTree(crashDirectory) { include '**/*' }.files.findAll { it.isFile() } + if (!crashes.isEmpty()) { + throw new GradleException("${context} produced crash report ${crashes.first()}") + } + } + + File logsDirectory = new File(runDirectory, 'logs') + if (!logsDirectory.isDirectory()) return + def failures = [] + fileTree(logsDirectory) { include '**/*.log'; include '**/*.txt' }.files.each { File log -> + int lineNumber = 0 + log.eachLine('UTF-8') { String line -> + lineNumber++ + boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ + boolean knownNoise = acceptedForge14LogNoise.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')) + 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. This is not a good thing\n' + + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe') + 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-rejection-probe') } + 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/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-error-severity-probe') } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') + delete probe + } } +check.dependsOn runtimeLogScannerTest + def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { source fileTree('src/biomeIntegrationTest/java') @@ -295,9 +379,13 @@ surfaceIntegrationFreshProcess.doLast { if (!marker.isFile()) { throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, 'surface integration fresh phase') } def surfaceIntegrationReloadProcess = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) useForge14Runtime(surfaceIntegrationReloadProcess) +surfaceIntegrationReloadProcess.doLast { + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, 'surface integration reload phase') +} task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { group = 'verification' @@ -375,10 +463,7 @@ if (project.hasProperty('migrationRunDir')) { throw new GradleException("Migration ${phase} phase did not complete: ${marker}") } def latest = new File(migrationRunDirectory, 'logs/latest.log') - if (latest.isFile() && (latest.text.contains('Migration audit failed') - || latest.text.contains('Encountered an unexpected exception'))) { - throw new GradleException("Migration ${phase} phase logged a server failure: ${latest}") - } + assertRuntimeLogsClean(migrationRunDirectory, "migration ${phase} phase") if (latest.isFile() && (project.findProperty('migrationAllowMissingMappings') ?: 'false') == 'true') { def protectedMissing = latest.text =~ /(?m)^(?:Missing (?:basemetals|orespawn):|\s+(?:basemetals|mmdlib|orespawn):)/ @@ -391,6 +476,98 @@ if (project.hasProperty('migrationRunDir')) { useForge14Runtime(migrationIntegrationProcess) } +// Two sealed existing-world upgrades exercise the distinct Mineralogy configs +// that can reach Forge 1.12: a carried 1.10 file and the native 1.12 file. +def legacyMineralogyArchive = file("${rootDir}/../migration-fixtures/sources/worlds/os3-331-default-source.zip") +def legacyMineralogyJar = file("${rootDir}/../migration-fixtures/sources/artifacts/Mineralogy-1.12.2-3.8.0.53.jar") +def legacyMineralogyGates = [] +[ + '110': [version: '3.3.8.26', config: '''\ +world-gen { + I:GEOME_SIZE=144 + B:REALISTIC_COAL_LAYERS=true + S:ROCK_LAYER_NOISE=41.5 + I:ROCK_LAYER_THICKNESS=11 +} +'''], + '112': [version: '3.8.0.53', config: '''\ +world-gen { + B:PLACE_MINERALOGY_ROCK=false + I:GEOME_SIZE=128 + S:ROCK_LAYER_NOISE=37.25 + I:ROCK_LAYER_THICKNESS=9 +} +'''] +].each { String lineage, Map fixture -> + String label = lineage == '110' ? '110' : '112' + File runDirectory = file("${buildDir}/legacy-mineralogy-${lineage}-run") + File mainOutput = file("${buildDir}/legacy-mineralogy-${lineage}-fixture/orespawn-main") + def prepareTask = task("prepareLegacyMineralogy${label}Run", + dependsOn: migrationIntegrationTestModJar) { + doLast { + if (!legacyMineralogyArchive.isFile() || !legacyMineralogyJar.isFile()) { + throw new GradleException('Sealed legacy Mineralogy fixtures are missing') + } + delete runDirectory + delete mainOutput + copy { from zipTree(legacyMineralogyArchive); into runDirectory } + mainOutput.mkdirs() + copy { from sourceSets.main.output; into mainOutput } + File mods = new File(runDirectory, 'mods'); mods.mkdirs() + copy { from migrationIntegrationTestModJar.archivePath; from legacyMineralogyJar; into mods } + File config = new File(runDirectory, 'config/mineralogy.cfg') + config.parentFile.mkdirs(); config.setText(fixture.config as String, 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + project.javaexec { + main = 'zone.moddev.mc.orespawn.migrationtest.LegacyMineralogyMetadataFixture' + classpath = files(migrationIntegrationClasses, sourceSets.main.runtimeClasspath) + args new File(runDirectory, 'world').absolutePath, fixture.version + } + } + } + + def createLegacyMineralogyProcess = { String phase, Object dependency -> + def process = task("legacyMineralogy${label}${phase.capitalize()}", type: JavaExec, + dependsOn: [dependency, 'createSrgToMcp']) { + group = 'verification' + main = 'net.minecraftforge.legacydev.MainServer' + classpath = files(sourceSets.main.runtimeClasspath, legacyDevRuntime) + workingDir runDirectory + environment 'mainClass', 'net.minecraft.launchwrapper.Launch' + environment 'MCP_TO_SRG', file("${buildDir}/createSrgToMcp/output.srg").absolutePath + environment 'MOD_CLASSES', mainOutput.absolutePath + environment 'tweakClass', 'net.minecraftforge.fml.common.launcher.FMLServerTweaker' + systemProperty 'forge.logging.console.level', 'info' + systemProperty 'orespawn.migrationFamily', "legacy-mineralogy-${lineage}" + systemProperty 'orespawn.migrationPhase', phase + args '--nogui' + doLast { + File marker = new File(runDirectory, 'world/orespawn4-migration-probe.properties') + if (!marker.isFile()) { + throw new GradleException("Legacy Mineralogy ${lineage} ${phase} marker is missing") + } + Properties values = new Properties(); marker.withInputStream { values.load(it) } + if (values.getProperty("${phase}_complete") != 'true') { + throw new GradleException("Legacy Mineralogy ${lineage} ${phase} did not complete") + } + assertRuntimeLogsClean(runDirectory, "legacy Mineralogy ${lineage} ${phase} phase") + } + } + useForge14Runtime(process) + process + } + + def freshTask = createLegacyMineralogyProcess('fresh', prepareTask) + def reloadTask = createLegacyMineralogyProcess('reload', freshTask) + def gate = task("legacyMineralogy${label}MigrationTest", dependsOn: reloadTask) { + group = 'verification' + description = "Proves Mineralogy ${lineage} settings remain exact across an OreSpawn 4 upgrade and reload." + } + legacyMineralogyGates.add(gate) +} + +check.dependsOn legacyMineralogyGates + if (project.hasProperty('os3AbiFixtureJar')) { def os3AbiFixture = file(project.property('os3AbiFixtureJar')) def os3AbiRunDirectory = file(project.findProperty('os3AbiRunDir') @@ -439,10 +616,7 @@ max-tick-time=-1 throw new GradleException("Published OS3 API fixture did not pass: ${marker}") } File latest = new File(os3AbiRunDirectory, 'logs/latest.log') - if (latest.isFile() && (latest.text.contains('Encountered an unexpected exception') - || latest.text.contains('Exception stopping the server'))) { - throw new GradleException("Published OS3 API fixture logged a server failure: ${latest}") - } + assertRuntimeLogsClean(os3AbiRunDirectory, 'published OS3 API fixture') } } useForge14Runtime(os3AbiCompatibilityProcess) @@ -468,6 +642,23 @@ task syncForge14EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { throw new GradleException("ForgeGradle generated an unexpected ${runName} launcher") } text = text.replace('value="${MC_VERSION}"', "value=\"${minecraft_version}\"") + String excludeTestKey = 'org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE' + String excludeTestAttribute = + "" + if (text.contains("key=\"${excludeTestKey}\"")) { + text = text.replaceFirst( + //, + excludeTestAttribute) + } else { + int launchHeaderEnd = text.indexOf('\n', text.indexOf('/serverconfig/orespawn-upgrade-report.txt`. OS3 rule and global-switch +imports are summarized in `config/orespawn-upgrade-report.txt` and retained in +machine-readable form at `config/orespawn-os3-migration-report.json`. ## Rocks And Geomes diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 5891ee4..5685866 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -27,6 +27,46 @@ use stable provider identities when their outputs match uniquely. Mineralogy 3 replacement rocks are accepted as ore hosts, but Mineralogy remains the authoritative geology engine when that legacy stack is installed. +## Mineralogy 1.10 And 1.12 Geology Handoff + +An existing world must not silently switch geology engines when Mineralogy is +updated to integrate with OreSpawn 4. If a generated world has no OreSpawn +world profile and its saved Forge mod list records Mineralogy 3 or earlier, +OreSpawn creates the first world profile in `legacy` mode before generating +new chunks. + +Mineralogy 1.10 and 1.12 used similar configuration files but not identical +contracts. OreSpawn therefore selects a lineage from the Mineralogy version in +`level.dat` (or the recoverable `level.dat_old`) and then consumes: + +- `GEOME_SIZE`, `ROCK_LAYER_NOISE`, and `ROCK_LAYER_THICKNESS` in both lines; +- every family whitelist and blacklist with the exact historical rock order; +- `REALISTIC_COAL_LAYERS` only for the 1.10 lineage; +- `PLACE_MINERALOGY_ROCK` only for native 1.12, including preserving `false`. + +A carried 1.10 file can become hybrid after Mineralogy 1.12 normalizes its own +Forge configuration. In that case the saved world version takes precedence: +1.10 retains its realistic-coal behavior and does not invent a later enable +flag, while 1.12 respects its native enable flag and does not enable realistic +coal. If the config was not copied, the published defaults for the selected +lineage are recorded instead. An existing OS4 profile is never overwritten. +Fresh worlds do not select legacy mode merely because a legacy config is still +installed. + +The snapshot is stored at `/serverconfig/orespawn-worldgen.json` and a +human-readable explanation is written to +`/serverconfig/orespawn-upgrade-report.txt`. The report has no timestamp +and is byte-stable across reload. OreSpawn does not rewrite the source +Mineralogy config or generated chunks during this handoff; the installed +Mineralogy version may independently normalize its own Forge config. Moving an +upgraded world to Sky later remains possible as an explicit choice, with an +expected old/new chunk seam. + +Legacy OreSpawn conversion also writes `config/orespawn-upgrade-report.txt`. +It summarizes consumed resources, translated providers, preserved global +flags, and warnings; `config/orespawn-os3-migration-report.json` remains the +deterministic machine-readable detail. + ## Provider-Aware OS3 Imports When an OreSpawn 2/3 file is named for an installed provider, the migrator now diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 661bef0..fd1eb52 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -88,6 +88,20 @@ Changes normally affect only chunks generated afterward. Existing terrain is not rewritten. Ore and flat-bedrock retrogen must be enabled deliberately; rock strata are never retro-generated. +For an existing Mineralogy 1.10 or 1.12 world, OreSpawn automatically keeps +the matching **Cyano (Legacy)** layout when it creates that world's first OS4 +profile. The old layer sizes, rock lists, enabled state, and applicable coal +setting are copied into the world before new chunks generate. A carried 1.10 +config and a native 1.12 config are handled separately, even if an upgrade has +left both generations of keys in the file. Fresh worlds still use the current +recommended engine. + +After upgrading, review `config/orespawn-upgrade-report.txt` and +`/serverconfig/orespawn-upgrade-report.txt`. They explain which legacy +files were consumed, which settings were preserved, and any registry names or +rules needing attention. Changing an upgraded world to Sky is deliberate and +can make newly generated chunks look different from old ones. + For a dedicated server, copy the whole world including that file and install the same mods. Alternatively, place a prepared global profile at `config/orespawn-worldgen.json` before creating a new server world. 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 8379234..ed28699 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -35,6 +35,7 @@ import zone.moddev.mc.orespawn.worldgen.SurfaceProbeSpringBridge; import net.minecraft.block.Block; +import net.minecraft.block.BlockDynamicLiquid; import net.minecraft.block.state.IBlockState; import net.minecraft.init.Blocks; import net.minecraft.init.Items; @@ -82,6 +83,7 @@ public final class SurfaceProbeTestMod { private static final ResourceLocation BIOME_B = new ResourceLocation(MODID, "surface_b"); private static final ResourceLocation PROBE_GEOME = new ResourceLocation(MODID, "exact_biome"); private static final ResourceLocation SPRING_ROCK = new ResourceLocation(MODID, "rock/spring_host"); + private static final ProbeLiquid DEPOSIT_FLUID = new ProbeLiquid(); private static final BlockPos SPRING_POS = new BlockPos(1128, 32, 1128); private static final int MIN_CHUNK = 63; private static final int MAX_CHUNK = 65; @@ -137,6 +139,11 @@ public void registerBiomes(RegistryEvent.Register event) { BiomeDictionary.addTypes(surfaceB.get(), BiomeDictionary.Type.HOT, BiomeDictionary.Type.WET); } + @SubscribeEvent + public void registerBlocks(RegistryEvent.Register event) { + event.getRegistry().register(DEPOSIT_FLUID); + } + @SubscribeEvent public void registerPatterns(RegistryEvent.Register event) { event.getRegistry().register(EXTERNAL_PATTERN); @@ -181,6 +188,9 @@ public void verifySurfaceStage(DecorateBiomeEvent.Pre event) { public void init(FMLInitializationEvent event) { WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); addGeology(provider); + provider.fluidDeposit(new ResourceLocation(MODID, "fluid_deposit/dynamic_tick_probe"), + id(DEPOSIT_FLUID), deposit -> deposit.dimension(OVERWORLD, + dimension -> dimension.hostBlock(id(Blocks.STONE)))); // This stable palette id makes seed zero select both fixture biomes on // opposite sides of the 1,024-block Tiny-region boundary. addPalette(provider, "end_palette_1", END, false); @@ -269,7 +279,18 @@ public void serverStarted(FMLServerStartedEvent event) { results.put("end", audit(requireWorld(server, 1), false)); results.put("nether", audit(requireWorld(server, -1), true)); ResourceLocation spring = auditSpring(overworld, phase); + int dynamicFluidPlacements = DEPOSIT_FLUID.placements(); + if ("fresh".equals(phase) && dynamicFluidPlacements <= 0) { + throw new IllegalStateException("OreSpawn did not place the dynamic fluid-deposit probe"); + } + if ("reload".equals(phase) && dynamicFluidPlacements != 0) { + throw new IllegalStateException("Reload generated new dynamic fluid deposits: " + + dynamicFluidPlacements); + } Properties current = properties(overworld.getSeed(), results, spring); + current.setProperty("dynamic_fluid_placements", "fresh".equals(phase) + ? Integer.toString(dynamicFluidPlacements) + : previous.getProperty("dynamic_fluid_placements")); if (previous == null) { write(marker, current); } else { @@ -549,6 +570,27 @@ private static final class Material { } } + /** Distinguishable dynamic liquid exercising vanilla's scheduled-tick retention path. */ + private static final class ProbeLiquid extends BlockDynamicLiquid { + private int placements; + + ProbeLiquid() { + super(net.minecraft.block.material.Material.LAVA); + setRegistryName(MODID, "dynamic_tick_probe"); + setTranslationKey(MODID + ".dynamic_tick_probe"); + } + + @Override + public void onBlockAdded(World world, BlockPos pos, IBlockState state) { + placements++; + super.onBlockAdded(world, pos, state); + } + + int placements() { + return placements; + } + } + private static final class ProbeDecorator extends BiomeDecorator { @Override public void decorate(World world, Random random, Biome biome, BlockPos pos) { diff --git a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java index 93ba753..a1c85ef 100644 --- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java +++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java @@ -796,9 +796,60 @@ private static void writeReport(Path destination) { JsonObject report = new JsonObject(); report.addProperty("format", 1); report.addProperty("idempotent", true); JsonArray rows = new JsonArray(); for (String row : REPORT) rows.add(row); report.add("entries", rows); writeAtomicIfChanged(destination, report); + writeHumanUpgradeReport(destination.resolveSibling("orespawn-upgrade-report.txt")); } catch (IOException failure) { LOGGER.error("Could not write OS3 migration report", failure); } } + private static void writeHumanUpgradeReport(Path destination) throws IOException { + Set sources = new LinkedHashSet<>(); + Set providers = new LinkedHashSet<>(); + Set warnings = new LinkedHashSet<>(); + Set details = new LinkedHashSet<>(REPORT); + for (String row : REPORT) { + String lower = row.toLowerCase(java.util.Locale.ROOT); + if (lower.startsWith("config_source=") || lower.startsWith("resource_loaded=") + || lower.startsWith("config_registered=")) sources.add(row); + if ((lower.startsWith("provider_written=") || lower.startsWith("provider_unchanged=")) + && !lower.contains("migration-report")) providers.add(row); + if (lower.contains("_failed=") || lower.contains("_rejected=") + || lower.contains("_ignored=") || lower.contains("_unresolved=") + || lower.contains("_clamped=") || lower.startsWith("resource_missing=")) { + warnings.add(row); + } + } + List lines = new ArrayList<>(); + lines.add("OreSpawn 4.0.6 Upgrade Report"); + lines.add("================================"); + lines.add(""); + lines.add("RESULT: Legacy OreSpawn configuration was consumed and translated for OS4."); + lines.add("- Legacy sources read: " + sources.size()); + lines.add("- OS4 provider files written or verified: " + providers.size()); + lines.add("- Unique items requiring review: " + warnings.size()); + lines.add(""); + lines.add(warnings.isEmpty() + ? "WARNINGS: None reported during translation." + : "WARNINGS: Review rejected, ignored, unresolved, clamped, missing, or failed entries below."); + lines.add(""); + lines.add("Detailed migration entries"); + for (String row : details) lines.add("- " + row); + lines.add(""); + lines.add("Machine-readable details: " + + destination.resolveSibling("orespawn-os3-migration-report.json").toAbsolutePath()); + lines.add("Original legacy configuration files were retained unchanged."); + byte[] data = (String.join(System.lineSeparator(), lines) + System.lineSeparator()) + .getBytes(StandardCharsets.UTF_8); + if (Files.isRegularFile(destination) && Arrays.equals(Files.readAllBytes(destination), data)) return; + Path temporary = destination.resolveSibling(destination.getFileName() + ".tmp"); + Files.write(temporary, data); + try { + Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (IOException failure) { + Files.deleteIfExists(temporary); + throw failure; + } + } + private static JsonObject readObject(InputStream input) throws IOException { try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { JsonElement value = new JsonParser().parse(reader); if (!value.isJsonObject()) throw new IOException("root is not an object"); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java index 676e60d..8716add 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java @@ -41,6 +41,7 @@ public final class FluidDepositFeature { private static final Logger LOGGER = LogManager.getLogger(); public static final FluidDepositFeature FEATURE = new FluidDepositFeature(); private static final int CHUNK_WIDTH = 16; + private static final int GENERATION_WRITE_FLAGS = 2 | 16; private static final BakedDeposit[] NO_DEPOSITS = new BakedDeposit[0]; private static final Map EMPTY_DIMENSIONS = Collections.emptyMap(); private static final Object CLASSIFIER_LOCK = new Object(); @@ -196,7 +197,9 @@ private static boolean placeLobe(World world, Chunk chunk, BakedDeposit deposit, cursor.setPos(x, y, z); // Output was validated while baking; keep a final runtime guard for registry oddities. if (deposit.output.getBlock() != Blocks.AIR && isFluidBlock(deposit.output)) { - chunk.setBlockState(cursor, deposit.output); + // Forge's world write snapshots MutableBlockPos before onBlockAdded can + // schedule a dynamic-fluid tick. Direct Chunk writes leak this cursor. + world.setBlockState(cursor, deposit.output, GENERATION_WRITE_FLAGS); changed = true; } } 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 4b7b0e5..4228ec5 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; @@ -13,8 +15,13 @@ import net.minecraft.world.World; import net.minecraft.util.ResourceLocation; import net.minecraft.world.biome.Biome; +import net.minecraftforge.fml.common.registry.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; @@ -22,10 +29,32 @@ public class Geology { private final IBlockState[] metamorphicStones; private final IBlockState[] 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, IBlockState[] igneousStones, + IBlockState[] metamorphicStones, IBlockState[] sedimentaryStones) { this.layerThickness = layerThickness; + this.realisticCoalLayers = realisticCoalLayers; int rockLayerUndertones = 4; int undertoneMultiplier = 1 << (rockLayerUndertones - 1); geomeNoiseLayer = new PerlinNoise2D(~seed, 128, (float) geomeSize, 2); @@ -38,9 +67,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) { @@ -81,7 +110,8 @@ public void replaceStoneInChunk(World world, Chunk chunk, BakedTerrainDimension for (; y >= 0; y--) { cursor.setPos(x, y, z); IBlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current)) { + if (terrain.isReplaceable(current) + || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) { IBlockState replacement = pickReplacement(baseRockVal, geomeBase, y); if (!GeomeGeology.changes(current, replacement)) continue; chunk.setBlockState(cursor, replacement); @@ -132,4 +162,30 @@ private IBlockState pickStateFromList(int value, IBlockState[] list) { return list[whiteNoiseArray[(value / layerThickness) & 0xFF] % list.length]; } + static IBlockState[] resolveRockOrder(WorldGeologyProfile profile, String key, + IBlockState[] fallback) { + if (!profile.hasCyanoRockOrder(key)) return fallback; + List states = new ArrayList<>(); + for (String idText : profile.cyanoRockOrder(key)) { + try { + ResourceLocation id = new ResourceLocation(idText); + Block block = ForgeRegistries.BLOCKS.containsKey(id) + ? ForgeRegistries.BLOCKS.getValue(id) : null; + if (block != null && block != Blocks.AIR) { + states.add(block.getDefaultState()); + } else { + LOGGER.warn("Legacy Cyano rock '{}' is not registered and will be omitted", id); + } + } catch (RuntimeException e) { + LOGGER.warn("Legacy Cyano rock registry name '{}' is invalid and will be omitted", idText); + } + } + if (states.isEmpty()) { + LOGGER.warn("No snapshotted legacy Cyano rocks for '{}' are registered; " + + "using the matching provider family as a safe fallback", key); + return fallback; + } + return states.toArray(new IBlockState[states.size()]); + } + } 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..0ebc8ba --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -0,0 +1,398 @@ +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.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import net.minecraft.nbt.CompressedStreamTools; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.common.registry.ForgeRegistries; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; + +/** + * Snapshots the exact legacy Mineralogy geology contract before OreSpawn owns + * geology for an existing world. Mineralogy 1.10 and 1.12 used related but + * distinct configs and rock orders, so their lineages are deliberately kept + * separate instead of being treated as one generic Cyano preset. + */ +final class LegacyMineralogyProfileMigration { + private static final Logger LOGGER = LogManager.getLogger(); + private static final String MINERALOGY_CONFIG = "mineralogy.cfg"; + + private static final List IGNEOUS_110 = Arrays.asList( + "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 = Arrays.asList( + "mineralogy:hornfels", "mineralogy:quartzite", "mineralogy:novaculite", + "mineralogy:slate", "mineralogy:schist", "mineralogy:gneiss", + "mineralogy:phyllite", "mineralogy:amphibolite"); + private static final List SEDIMENTARY_110_BEFORE_COAL = Arrays.asList( + "mineralogy:siltstone", "mineralogy:shale", "mineralogy:conglomerate", + "mineralogy:dolomite", "mineralogy:limestone", "mineralogy:marble", + "minecraft:sandstone"); + private static final List SEDIMENTARY_110_AFTER_COAL = Arrays.asList( + "mineralogy:chert", "mineralogy:gypsum", "mineralogy:chalk", + "mineralogy:rock_salt"); + + private static final List IGNEOUS_112 = Arrays.asList( + "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 = Arrays.asList( + "mineralogy:slate", "mineralogy:schist", "mineralogy:gneiss", + "mineralogy:phyllite", "mineralogy:amphibolite", "mineralogy:hornfels", + "mineralogy:quartzite", "mineralogy:novaculite"); + private static final List SEDIMENTARY_112 = Arrays.asList( + "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"); + + 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; + + Path configPath = configDirectory.resolve(MINERALOGY_CONFIG); + Map values = readConfig(configPath); + boolean configFound = Files.isRegularFile(configPath); + Lineage lineage = lineage(identity.version, values); + boolean hybridConfig = values.containsKey("place_mineralogy_rock") + && values.containsKey("realistic_coal_layers"); + boolean enabled = lineage == Lineage.MINERALOGY_112 + ? bool(values, "place_mineralogy_rock", true) : 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); + + List igneous = legacyList(lineage == Lineage.MINERALOGY_110 + ? IGNEOUS_110 : IGNEOUS_112, values, + "igneous_whitelist", "igneous_blacklist"); + List metamorphic = legacyList(lineage == Lineage.MINERALOGY_110 + ? METAMORPHIC_110 : METAMORPHIC_112, values, + "metamorphic_whitelist", "metamorphic_blacklist"); + 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<>(SEDIMENTARY_112); + } + List sedimentary = legacyList(sedimentaryBase, values, + "sedimentary_whitelist", "sedimentary_blacklist"); + + JsonObject root = installedPackProfile.rootCopy(); + root.addProperty("geology_mode", GeologyMode.LEGACY.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_metadata_source", identity.sourceFile); + 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)); + root.add("cyano", cyano); + + writeUpgradeReport(worldRoot, configDirectory, identity, lineage, + configFound, hybridConfig, enabled, geomeSize, rockLayerNoise, + layerThickness, realisticCoal, igneous, metamorphic, sedimentary); + + LOGGER.info("Existing Mineralogy {} world detected from {}; pinned OreSpawn to {} " + + "Cyano behavior (enabled={}, geomeSize={}, layerNoise={}, layerThickness={}, " + + "realisticCoal={}, hybridConfig={}, configFound={})", + identity.version, identity.sourceFile, lineage.label, enabled, geomeSize, + rockLayerNoise, layerThickness, realisticCoal, hybridConfig, configFound); + return WorldGeologyProfile.fromJson(root, installedPackProfile); + } + + private static void writeUpgradeReport(Path worldRoot, Path configDirectory, + MineralogyIdentity identity, Lineage lineage, boolean configFound, + boolean hybridConfig, boolean enabled, int geomeSize, + double rockLayerNoise, int layerThickness, boolean realisticCoal, + List igneous, List metamorphic, List sedimentary) { + Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); + List missing = missingBlocks(igneous, metamorphic, sedimentary); + List lines = new ArrayList<>(); + lines.add("OreSpawn 4.0.6 Upgrade Report"); + lines.add("================================"); + lines.add(""); + lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); + lines.add(enabled + ? "Geology remains on the Cyano engine using " + lineage.label + " behavior." + : "Legacy Mineralogy geology was disabled and remains disabled for this world."); + lines.add("This prevents an automatic worldgen 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("- Hybrid 1.10/1.12 keys found: " + hybridConfig); + if (hybridConfig) { + lines.add("- Hybrid precedence: saved Mineralogy version and the native 1.12 enable flag " + + "select behavior; 1.10 realistic coal is used only for the 1.10 lineage."); + } + lines.add(""); + lines.add("Legacy Mineralogy configuration"); + lines.add("- Source: " + configDirectory.resolve(MINERALOGY_CONFIG).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_112 ? " (not supported by Mineralogy 1.12)" : "")); + lines.add("- Igneous rock order (" + igneous.size() + "): " + String.join(", ", igneous)); + lines.add("- Metamorphic rock order (" + metamorphic.size() + "): " + String.join(", ", metamorphic)); + lines.add("- Sedimentary rock order (" + sedimentary.size() + "): " + String.join(", ", sedimentary)); + lines.add(""); + Path os3Report = configDirectory.resolve("orespawn-os3-migration-report.json"); + lines.add("Legacy OreSpawn rules"); + lines.add("- Detailed OS3 rule report: " + os3Report.toAbsolutePath()); + lines.add("- Rule report found: " + (Files.isRegularFile(os3Report) + ? "yes" : "no (no legacy rule conversion was recorded here)")); + lines.add(""); + if (missing.isEmpty()) { + lines.add("WARNINGS: None. Every preserved Mineralogy rock ID is registered."); + } else { + lines.add("WARNINGS: These preserved rock IDs are not currently registered and need review:"); + for (String id : missing) lines.add("- " + id); + } + lines.add(""); + lines.add("OreSpawn did not rewrite the source Mineralogy configuration or existing chunks."); + lines.add("The installed Mineralogy version may still normalize its own Forge configuration file."); + lines.add("To change this world's geology later, make that choice explicitly and expect a generation seam."); + writeTextAtomically(report, lines); + } + + private static List missingBlocks(List... families) { + List missing = new ArrayList<>(); + 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 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())) { + NBTTagCompound root = CompressedStreamTools.readCompressed(input); + NBTTagCompound fml = root.getCompoundTag("FML"); + NBTTagList mods = fml.getTagList("ModList", 10); + for (int i = 0; i < mods.tagCount(); i++) { + NBTTagCompound mod = mods.getCompoundTagAt(i); + if (!"mineralogy".equalsIgnoreCase(mod.getString("ModId"))) continue; + String version = mod.getString("ModVersion").trim(); + if (isLegacyVersion(version)) { + return new MineralogyIdentity(version.isEmpty() ? "legacy" : version, fileName); + } + return null; + } + } catch (IOException | RuntimeException e) { + LOGGER.warn("Could not inspect '{}' for legacy Mineralogy metadata", levelDat, e); + } + } + return null; + } + + private static boolean isLegacyVersion(String version) { + if (version == null || version.trim().isEmpty()) return true; + List parts = versionParts(version); + return !parts.isEmpty() && parts.get(0) <= 3; + } + + private static Lineage lineage(String version, Map values) { + String lower = version == null ? "" : version.toLowerCase(Locale.ROOT); + if (lower.contains("1.10")) return Lineage.MINERALOGY_110; + if (lower.contains("1.12")) return Lineage.MINERALOGY_112; + List parts = versionParts(version); + if (parts.size() >= 2 && parts.get(0) == 3) { + if (parts.get(1) >= 8) return Lineage.MINERALOGY_112; + if (parts.get(1) <= 3) return Lineage.MINERALOGY_110; + } + if (values.containsKey("place_mineralogy_rock")) return Lineage.MINERALOGY_112; + if (values.containsKey("realistic_coal_layers")) return Lineage.MINERALOGY_110; + return Lineage.MINERALOGY_112; + } + + 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 Map readConfig(Path configPath) { + Map values = new LinkedHashMap<>(); + if (!Files.isRegularFile(configPath)) return values; + try (BufferedReader reader = Files.newBufferedReader(configPath, 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 = trimmed.substring(2, equals).trim().toLowerCase(Locale.ROOT); + values.put(key, trimmed.substring(equals + 1).trim()); + } + } catch (IOException e) { + LOGGER.warn("Could not read legacy Mineralogy configuration '{}'; using published defaults", + configPath, e); + values.clear(); + } + return values; + } + + private static List legacyList(List defaults, Map values, + String whitelistKey, String blacklistKey) { + List result = new ArrayList<>(defaults); + for (String id : splitIds(values.get(whitelistKey))) result.add(id); + for (String id : splitIds(values.get(blacklistKey))) result.remove(id); + return result; + } + + private static List splitIds(String configured) { + List result = new ArrayList<>(); + if (configured == null) return result; + for (String raw : configured.split(";")) { + String value = raw.trim(); + if (value.isEmpty()) continue; + try { result.add(new ResourceLocation(value).toString()); } + catch (RuntimeException e) { + LOGGER.warn("Ignoring invalid legacy Mineralogy rock registry name '{}'", value); + } + } + return result; + } + + private static int integer(Map values, String key, int fallback, int min, int max) { + try { + int value = values.containsKey(key) ? Integer.parseInt(values.get(key)) : fallback; + return Math.max(min, Math.min(max, value)); + } catch (RuntimeException e) { return fallback; } + } + + private static double decimal(Map values, String key, + double fallback, double min, double max) { + try { + double value = values.containsKey(key) ? Double.parseDouble(values.get(key)) : fallback; + return Math.max(min, Math.min(max, value)); + } catch (RuntimeException e) { return fallback; } + } + + private static boolean bool(Map values, String key, boolean fallback) { + if (!values.containsKey(key)) return fallback; + String value = values.get(key); + return "true".equalsIgnoreCase(value) ? true + : "false".equalsIgnoreCase(value) ? false : fallback; + } + + private static JsonArray array(List values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private enum Lineage { + MINERALOGY_110("Mineralogy 1.10"), + MINERALOGY_112("Mineralogy 1.12"); + + final String label; + Lineage(String label) { this.label = label; } + } + + private static final class MineralogyIdentity { + final String version; + final String sourceFile; + + MineralogyIdentity(String version, String sourceFile) { + this.version = version; + this.sourceFile = sourceFile; + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java index 3503e5d..5cd8b3b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java @@ -53,10 +53,35 @@ public static Result apply(Path configDirectory, boolean manageVanilla, boolean if (!Files.exists(backup)) Files.copy(destination, backup); } atomicWrite(destination, bytes); + writeInitialUpgradeReport(configDirectory, manageVanilla, suppressAll, + retrogen, forceRetrogen, flatBedrock, retrogenBedrock, + Math.max(1, Math.min(4, bedrockLayers))); atomicWrite(marker, ("profile_schema=6" + System.lineSeparator()).getBytes(StandardCharsets.UTF_8)); return Result.WRITTEN; } + private static void writeInitialUpgradeReport(Path configDirectory, + boolean manageVanilla, boolean suppressAll, boolean retrogen, + boolean forceRetrogen, boolean flatBedrock, boolean retrogenBedrock, + int bedrockLayers) throws IOException { + String newline = System.lineSeparator(); + String text = "OreSpawn 4.0.6 Upgrade Report" + newline + + "================================" + newline + newline + + "RESULT: Legacy OreSpawn settings were imported into the OS4 profile." + newline + + "- Manage vanilla ores: " + manageVanilla + newline + + "- Suppress all ore features: " + suppressAll + newline + + "- Retrogen enabled: " + retrogen + newline + + "- Force retrogen: " + forceRetrogen + newline + + "- Flat bedrock enabled: " + flatBedrock + newline + + "- Flat bedrock retrogen: " + retrogenBedrock + newline + + "- Flat bedrock layers: " + bedrockLayers + newline + newline + + "The detailed OS3 rule translation is recorded in " + + "orespawn-os3-migration-report.json." + newline + + "Original legacy configuration files are retained unchanged." + newline; + atomicWrite(configDirectory.resolve("orespawn-upgrade-report.txt"), + text.getBytes(StandardCharsets.UTF_8)); + } + private static JsonObject readExisting(Path path) throws IOException { if (!Files.isRegularFile(path)) return null; try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { 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 cabdf2c..b140e45 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -36,7 +36,9 @@ boolean generate(World world, Chunk chunk, Random random) { ResourceLocation dimension = WorldIds.dimension(world); BakedTerrainDimension terrain = GeomeConfig.terrainDimension(dimension); BakedGeomeConfig config = GeomeConfig.baked(dimension); - if (!OreSpawnConfig.placeOreSpawnRock() || terrain == null || config == null) { + WorldGeologyProfile profile = WorldGeologyProfileManager.activeProfile(); + if (!OreSpawnConfig.placeOreSpawnRock() || terrain == null || config == null + || (profile.geologyMode() == GeologyMode.LEGACY && !profile.cyanoEnabled())) { return false; } @@ -60,8 +62,7 @@ private CachedGeology geology(ResourceLocation dimension, long seed, if (current == null || current.seed != seed || current.mode != mode) { WorldGeologyProfile profile = WorldGeologyProfileManager.activeProfile(); current = mode == GeologyMode.LEGACY - ? new CachedGeology(seed, mode, new Geology(seed, profile.cyanoGeomeSize(), - profile.cyanoRockLayerNoise(), profile.cyanoLayerThickness(), config), null) + ? new CachedGeology(seed, mode, new Geology(seed, profile, config), null) : new CachedGeology(seed, mode, null, new GeomeGeology(seed, config)); geologyByDimension.put(dimension, current); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java index ea34110..179cfb5 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,29 @@ 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); + } + + 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 c1e0473..5a40998 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -22,6 +22,7 @@ import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; import net.minecraft.server.MinecraftServer; +import net.minecraftforge.fml.common.Loader; import net.minecraftforge.fml.common.event.FMLServerAboutToStartEvent; import net.minecraftforge.fml.common.event.FMLServerStoppedEvent; import net.minecraftforge.event.world.WorldEvent; @@ -133,15 +134,23 @@ 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, Loader.instance().getConfigDir().toPath(), + 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/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/LegacyMineralogyMetadataFixture.java b/src/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/LegacyMineralogyMetadataFixture.java new file mode 100644 index 0000000..24cc458 --- /dev/null +++ b/src/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/LegacyMineralogyMetadataFixture.java @@ -0,0 +1,51 @@ +package zone.moddev.mc.orespawn.migrationtest; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import net.minecraft.nbt.CompressedStreamTools; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; + +/** Build-only helper which labels a copied 1.12 save with its prior Mineralogy version. */ +public final class LegacyMineralogyMetadataFixture { + private LegacyMineralogyMetadataFixture() { } + + public static void main(String[] arguments) throws Exception { + if (arguments.length != 2) { + throw new IllegalArgumentException("Expected world directory and Mineralogy version"); + } + Path world = Paths.get(arguments[0]); + for (String name : new String[] { "level.dat", "level.dat_old" }) { + Path levelDat = world.resolve(name); + if (!Files.isRegularFile(levelDat)) continue; + NBTTagCompound root; + try (FileInputStream input = new FileInputStream(levelDat.toFile())) { + root = CompressedStreamTools.readCompressed(input); + } + NBTTagCompound fml = root.getCompoundTag("FML"); + NBTTagList mods = fml.getTagList("ModList", 10); + boolean found = false; + for (int index = 0; index < mods.tagCount(); index++) { + NBTTagCompound mod = mods.getCompoundTagAt(index); + if (!"mineralogy".equalsIgnoreCase(mod.getString("ModId"))) continue; + mod.setString("ModVersion", arguments[1]); + found = true; + } + if (!found) { + NBTTagCompound mineralogy = new NBTTagCompound(); + mineralogy.setString("ModId", "mineralogy"); + mineralogy.setString("ModVersion", arguments[1]); + mods.appendTag(mineralogy); + } + fml.setTag("ModList", mods); + root.setTag("FML", fml); + try (FileOutputStream output = new FileOutputStream(levelDat.toFile())) { + CompressedStreamTools.writeCompressed(root, output); + } + } + } +} diff --git a/src/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/MigrationCorpusPresenceTestMod.java b/src/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/MigrationCorpusPresenceTestMod.java new file mode 100644 index 0000000..8c2cecc --- /dev/null +++ b/src/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/MigrationCorpusPresenceTestMod.java @@ -0,0 +1,13 @@ +package zone.moddev.mc.orespawn.migrationtest; + +import net.minecraftforge.fml.common.Mod; + +/** + * Build-only identity placeholder for worlds created by the sealed corpus + * generator. It satisfies Forge's saved-mod audit without rerunning that + * generator or allowing it to stop the qualification server. + */ +@Mod(modid = "orespawnmigrationcorpus", name = "OreSpawn Migration Corpus Placeholder", + version = "1.0.0", acceptedMinecraftVersions = "[1.12.2]") +public final class MigrationCorpusPresenceTestMod { +} diff --git a/src/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/MigrationProbeTestMod.java b/src/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/MigrationProbeTestMod.java index c2df5b9..b0f19dd 100644 --- a/src/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/MigrationProbeTestMod.java +++ b/src/migrationIntegrationTest/java/zone/moddev/mc/orespawn/migrationtest/MigrationProbeTestMod.java @@ -152,6 +152,7 @@ private void finish() throws IOException { Properties audit = auditWorld(); if (isBaseMetalsFreshInstallFixture()) validateBaseMetalsFreshInstall(root, audit); + if (isLegacyMineralogyFixture()) validateLegacyMineralogy(root, values); Path freshAudit = root.resolve("orespawn4-migration-fresh-audit.properties"); if ("fresh".equals(phase)) { write(freshAudit, audit, "OreSpawn 4 migration fresh semantic audit"); @@ -195,6 +196,83 @@ private boolean isBaseMetalsFreshInstallFixture() { return BASE_METALS_FRESH_INSTALL.equals(family); } + private boolean isLegacyMineralogyFixture() { + return "legacy-mineralogy-110".equals(family) + || "legacy-mineralogy-112".equals(family) + || "current-112-stack-postfix".equals(family); + } + + private void validateLegacyMineralogy(Path worldRoot, Properties marker) throws IOException { + boolean lineage110 = "legacy-mineralogy-110".equals(family); + boolean current112Stack = "current-112-stack-postfix".equals(family); + Path config = worldRoot.getParent().resolve("config/mineralogy.cfg"); + Path profile = worldRoot.resolve("serverconfig/orespawn-worldgen.json"); + Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); + JsonObject root = readJson(profile); + JsonObject cyano = root.getAsJsonObject("cyano"); + String expectedLineage = lineage110 ? "Mineralogy 1.10" : "Mineralogy 1.12"; + boolean expectedEnabled = lineage110 || current112Stack; + boolean expectedCoal = lineage110; + int expectedGeomeSize = current112Stack ? 100 : lineage110 ? 144 : 128; + double expectedNoise = current112Stack ? 32.0D : lineage110 ? 41.5D : 37.25D; + int expectedThickness = current112Stack ? 8 : lineage110 ? 11 : 9; + if (!"legacy".equals(root.get("geology_mode").getAsString()) || cyano == null + || !expectedLineage.equals(cyano.get("legacy_lineage").getAsString()) + || cyano.get("enabled").getAsBoolean() != expectedEnabled + || cyano.get("realistic_coal_layers").getAsBoolean() != expectedCoal + || cyano.get("geome_size").getAsInt() != expectedGeomeSize + || Double.compare(cyano.get("rock_layer_noise").getAsDouble(), + expectedNoise) != 0 + || cyano.get("rock_layer_thickness").getAsInt() != expectedThickness + || !cyano.get("legacy_config_found").getAsBoolean()) { + throw new IllegalStateException("Existing " + expectedLineage + + " world was not pinned to its exact Cyano settings: " + cyano); + } + JsonArray igneous = cyano.getAsJsonArray("igneous_rocks"); + JsonArray sedimentary = cyano.getAsJsonArray("sedimentary_rocks"); + assertRockOrder(igneous, 13, + lineage110 ? "mineralogy:diabase" : "mineralogy:andesite", + "mineralogy:pumice"); + if (lineage110) { + if (sedimentary.size() != 12 + || !"minecraft:coal_ore".equals(sedimentary.get(7).getAsString())) { + throw new IllegalStateException("Mineralogy 1.10 realistic coal order was not retained"); + } + } else { + if (sedimentary.size() != 12 + || !"mineralogy:rock_salt".equals(sedimentary.get(10).getAsString()) + || !"mineralogy:rock_salt".equals(sedimentary.get(11).getAsString())) { + throw new IllegalStateException("Mineralogy 1.12 duplicate rock-salt order was not retained"); + } + } + String reportText = new String(Files.readAllBytes(report), StandardCharsets.UTF_8); + if (!reportText.contains("Selected config lineage: " + expectedLineage) + || !reportText.contains("Geology enabled: " + expectedEnabled) + || !reportText.contains("OreSpawn did not rewrite the source Mineralogy configuration or existing chunks")) { + throw new IllegalStateException("Legacy Mineralogy human report is incomplete: " + report); + } + + for (String[] file : new String[][] { + { "legacy_mineralogy_config_sha256", config.toString() }, + { "legacy_mineralogy_world_profile_sha256", profile.toString() }, + { "legacy_mineralogy_upgrade_report_sha256", report.toString() } }) { + String hash = sha256(java.nio.file.Paths.get(file[1])); + if ("fresh".equals(phase)) marker.setProperty(file[0], hash); + else if (!hash.equals(marker.getProperty(file[0]))) { + throw new IllegalStateException("Legacy Mineralogy migration changed on reload: " + file[1]); + } + } + } + + private static void assertRockOrder(JsonArray rocks, int expectedSize, + String first, String last) { + if (rocks == null || rocks.size() != expectedSize + || !first.equals(rocks.get(0).getAsString()) + || !last.equals(rocks.get(rocks.size() - 1).getAsString())) { + throw new IllegalStateException("Unexpected legacy Mineralogy rock order: " + rocks); + } + } + private Properties reloadComparison(Properties source) { if (!isBaseMetalsFreshInstallFixture()) return source; Properties stable = new Properties(); @@ -295,6 +373,14 @@ private void validateBaseMetalsFreshInstall(Path worldRoot, Properties audit) th "provider_written=basemetals-orespawn.json" }) { if (!entries.contains(required)) throw new IllegalStateException("Missing migration evidence: " + required); } + Path humanReport = config.resolve("orespawn-upgrade-report.txt"); + String humanReportText = new String(Files.readAllBytes(humanReport), StandardCharsets.UTF_8); + if (!humanReportText.contains("RESULT: Legacy OreSpawn configuration was consumed and translated for OS4.") + || !humanReportText.contains("Original legacy configuration files were retained unchanged.") + || !humanReportText.contains("Unique items requiring review: 0") + || !humanReportText.contains("WARNINGS: None reported during translation.")) { + throw new IllegalStateException("Base Metals human upgrade report is incomplete: " + humanReport); + } } private void verifyFreshInstallHashes(Path worldRoot, Properties marker) throws IOException { @@ -302,10 +388,11 @@ private void verifyFreshInstallHashes(Path worldRoot, Properties marker) throws Path[] files = { config.resolve("basemetals-orespawn.json"), config.resolve("orespawn-os3-migration-report.json"), + config.resolve("orespawn-upgrade-report.txt"), config.resolve("orespawn-worldgen.json"), worldRoot.resolve("serverconfig/orespawn-worldgen.json") }; - String[] names = { "provider", "report", "global_profile", "world_profile" }; + String[] names = { "provider", "report", "human_report", "global_profile", "world_profile" }; for (int index = 0; index < files.length; index++) { String hash = sha256(files[index]); String key = "fresh_basemetals_" + names[index] + "_sha256"; diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeatureTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeatureTest.java index 44c6ef4..a6da5d4 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeatureTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeatureTest.java @@ -3,14 +3,36 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import net.minecraft.util.ResourceLocation; import net.minecraft.init.Blocks; +import zone.moddev.mc.orespawn.test.Forge14TestBootstrap; class FluidDepositFeatureTest { + @BeforeAll + static void bootstrapMinecraftRegistries() { + Forge14TestBootstrap.registerVanilla(); + } + + @Test + void dynamicFluidWritesUseForgesPositionSnapshotInsteadOfLeakingTheReusableCursor() + throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "worldgen", "FluidDepositFeature.java")), + StandardCharsets.UTF_8); + assertTrue(source.contains("GENERATION_WRITE_FLAGS = 2 | 16")); + assertTrue(source.contains("world.setBlockState(cursor, deposit.output, GENERATION_WRITE_FLAGS)")); + assertFalse(source.contains("chunk.setBlockState(cursor, deposit.output)")); + } + @Test void explicitBiomeFiltersBakeAsStaticRegistryIds() { JsonObject rule = new JsonObject(); 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..bd6e44b --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -0,0 +1,214 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import zone.moddev.mc.orespawn.test.Forge14TestBootstrap; + +class LegacyMineralogyGeologyParityTest { + @BeforeAll + static void bootstrapMinecraftRegistries() { + Forge14TestBootstrap.registerVanilla(); + } + + @Test + void carried110SamplerMatchesPublishedMineralogyExactly() throws Exception { + try (PublishedMineralogy published = PublishedMineralogy.open( + "orespawn.mineralogy110Oracle", "cyano.mineralogy.worldgen.Geology")) { + Class mineralogy = published.load("cyano.mineralogy.Mineralogy"); + List igneousList = published.blockList(mineralogy, "igneousStones"); + List metamorphicList = published.blockList(mineralogy, "metamorphicStones"); + List sedimentaryList = published.blockList(mineralogy, "sedimentaryStones"); + List originalIgneous = new ArrayList<>(igneousList); + List originalMetamorphic = new ArrayList<>(metamorphicList); + List originalSedimentary = new ArrayList<>(sedimentaryList); + Field thickness = mineralogy.getField("GEOM_LAYER_THICKNESS"); + int originalThickness = thickness.getInt(null); + try { + Block[] igneous = { Blocks.STONE, Blocks.OBSIDIAN, Blocks.NETHERRACK }; + Block[] metamorphic = { Blocks.COBBLESTONE, Blocks.MOSSY_COBBLESTONE }; + Block[] sedimentary = { Blocks.SANDSTONE, Blocks.GRAVEL, Blocks.COAL_ORE }; + reset(igneousList, igneous); + reset(metamorphicList, metamorphic); + reset(sedimentaryList, sedimentary); + thickness.setInt(null, 11); + + for (long seed : seeds()) { + PublishedSampler sampler = published.newSampler( + new Class[] { long.class, double.class, double.class, boolean.class }, + seed, 144.0D, 41.5D, true); + Geology os4 = new Geology(seed, 144.0D, 41.5D, 11, true, + states(igneous), states(metamorphic), states(sedimentary)); + assertSamplerParity("1.10 Cyano", seed, sampler, os4); + } + } finally { + reset(igneousList, originalIgneous.toArray(new Block[0])); + reset(metamorphicList, originalMetamorphic.toArray(new Block[0])); + reset(sedimentaryList, originalSedimentary.toArray(new Block[0])); + thickness.setInt(null, originalThickness); + } + } + } + + @Test + void native112SamplerMatchesPublishedMineralogyExactly() throws Exception { + try (PublishedMineralogy published = PublishedMineralogy.open( + "orespawn.mineralogy112Oracle", "com.mcmoddev.mineralogy.worldgen.Geology")) { + Class registry = published.load("com.mcmoddev.mineralogy.init.MineralogyRegistry"); + List igneousList = published.blockList(registry, "igneousStones"); + List metamorphicList = published.blockList(registry, "metamorphicStones"); + List sedimentaryList = published.blockList(registry, "sedimentaryStones"); + List originalIgneous = new ArrayList<>(igneousList); + List originalMetamorphic = new ArrayList<>(metamorphicList); + List originalSedimentary = new ArrayList<>(sedimentaryList); + Class config = published.load("com.mcmoddev.mineralogy.MineralogyConfig"); + Field thickness = config.getDeclaredField("geomLayerThickness"); + thickness.setAccessible(true); + int originalThickness = thickness.getInt(null); + try { + 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 }; + reset(igneousList, igneous); + reset(metamorphicList, metamorphic); + reset(sedimentaryList, sedimentary); + thickness.setInt(null, 9); + + for (long seed : seeds()) { + PublishedSampler sampler = published.newSampler( + new Class[] { long.class, double.class, double.class }, + seed, 128.0D, 37.25D); + Geology os4 = new Geology(seed, 128.0D, 37.25D, 9, false, + states(igneous), states(metamorphic), states(sedimentary)); + assertSamplerParity("1.12 Cyano", seed, sampler, os4); + } + } finally { + reset(igneousList, originalIgneous.toArray(new Block[0])); + reset(metamorphicList, originalMetamorphic.toArray(new Block[0])); + reset(sedimentaryList, originalSedimentary.toArray(new Block[0])); + thickness.setInt(null, originalThickness); + } + } + } + + private static void assertSamplerParity(String label, long seed, + PublishedSampler published, Geology os4) throws Exception { + for (int x : coordinates()) { + for (int z : coordinates()) { + for (int y = 0; y < 256; y += 7) { + assertEquals(published.getStoneAt(x, y, z), os4.getStoneAt(x, y, z), + label + " mismatch seed=" + seed + " x=" + x + + " y=" + y + " z=" + z); + } + assertArrayEquals(published.getStoneColumn(x, z, 256), + os4.getStoneColumn(x, z, 256)); + } + } + } + + private static long[] seeds() { + return new long[] { 0L, -4965128775892001975L }; + } + + private static int[] coordinates() { + return new int[] { -1025, -257, -1, 0, 1, 255, 1024 }; + } + + private static void reset(List target, Block[] values) { + target.clear(); + for (Block value : values) target.add(value); + } + + private static IBlockState[] states(Block[] blocks) { + IBlockState[] states = new IBlockState[blocks.length]; + for (int i = 0; i < blocks.length; i++) states[i] = blocks[i].getDefaultState(); + return states; + } + + private static final class PublishedMineralogy implements AutoCloseable { + private final URLClassLoader loader; + private final Class geologyClass; + + private PublishedMineralogy(URLClassLoader loader, Class geologyClass) { + this.loader = loader; + this.geologyClass = geologyClass; + } + + static PublishedMineralogy open(String property, String geologyClassName) throws Exception { + String configuredPath = System.getProperty(property); + assertTrue(configuredPath != null && !configuredPath.trim().isEmpty(), + "Missing published Mineralogy oracle system property: " + property); + Path jar = Paths.get(configuredPath); + assertTrue(Files.isRegularFile(jar), "Published Mineralogy oracle is missing: " + jar); + URLClassLoader loader = new URLClassLoader(new URL[] { jar.toUri().toURL() }, + LegacyMineralogyGeologyParityTest.class.getClassLoader()); + try { + return new PublishedMineralogy(loader, + Class.forName(geologyClassName, true, loader)); + } catch (Throwable failure) { + loader.close(); + throw failure; + } + } + + Class load(String name) throws ClassNotFoundException { + return Class.forName(name, true, loader); + } + + @SuppressWarnings("unchecked") + List blockList(Class owner, String fieldName) throws Exception { + return (List) owner.getField(fieldName).get(null); + } + + PublishedSampler newSampler(Class[] parameterTypes, Object... arguments) + throws Exception { + Constructor constructor = geologyClass.getConstructor(parameterTypes); + return new PublishedSampler(constructor.newInstance(arguments), + geologyClass.getMethod("getStoneAt", int.class, int.class, int.class), + geologyClass.getMethod("getStoneColumn", int.class, int.class, int.class)); + } + + @Override + public void close() throws Exception { + loader.close(); + } + } + + private static final class PublishedSampler { + private final Object delegate; + private final Method getStoneAt; + private final Method getStoneColumn; + + private PublishedSampler(Object delegate, Method getStoneAt, Method getStoneColumn) { + this.delegate = delegate; + this.getStoneAt = getStoneAt; + this.getStoneColumn = getStoneColumn; + } + + Block getStoneAt(int x, int y, int z) throws Exception { + return (Block) getStoneAt.invoke(delegate, x, y, z); + } + + Block[] getStoneColumn(int x, int z, int height) throws Exception { + return (Block[]) getStoneColumn.invoke(delegate, x, z, height); + } + } +} 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..13b069b --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java @@ -0,0 +1,326 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.nbt.CompressedStreamTools; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +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; +import zone.moddev.mc.orespawn.test.Forge14TestBootstrap; + +class LegacyMineralogyProfileMigrationTest { + @BeforeAll + static void bootstrapMinecraftRegistries() { + Forge14TestBootstrap.registerVanilla(); + } + + @Test + void carriedMineralogy110ConfigRetainsItsExactCyanoContract(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "3.3.8.26"); + Path config = config(root, + "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 originalHash = 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", cyano.get("legacy_lineage").getAsString()); + assertEquals(list( + "mineralogy:diabase", "mineralogy:peridotite", "mineralogy:basaltic_glass", + "mineralogy:scoria", "mineralogy:tuff", "mineralogy:andesite", + "mineralogy:basalt", "mineralogy:diorite", "mineralogy:granite", + "mineralogy:rhyolite", "mineralogy:pegmatite", "mineralogy:pumice", + "minecraft:obsidian", "mineralogy:diabase"), strings(cyano, "igneous_rocks")); + assertTrue(strings(cyano, "sedimentary_rocks").contains("minecraft:coal_ore")); + assertEquals(originalHash, sha256(config.resolve("mineralogy.cfg"))); + assertReport(world, "Selected config lineage: Mineralogy 1.10", + "Realistic coal layers: true", "Geology remains on the Cyano engine"); + } + + @Test + void nativeMineralogy112ConfigRetainsItsDifferentRockOrder(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "3.8.0.53"); + Path config = config(root, + "B:PLACE_MINERALOGY_ROCK=true\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"); + + assertTrue(migrated.cyanoEnabled()); + assertFalse(migrated.cyanoRealisticCoalLayers()); + assertEquals("Mineralogy 1.12", cyano.get("legacy_lineage").getAsString()); + assertEquals(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"), strings(cyano, "igneous_rocks")); + List sedimentary = strings(cyano, "sedimentary_rocks"); + assertEquals(12, sedimentary.size()); + assertEquals(2, count(sedimentary, "mineralogy:rock_salt"), + "Mineralogy 1.12 registered rock salt twice and its sampler saw both entries"); + assertFalse(sedimentary.contains("minecraft:coal_ore")); + assertReport(world, "Selected config lineage: Mineralogy 1.12", + "Realistic coal layers: false (not supported by Mineralogy 1.12)"); + } + + @Test + void native112DisabledGeologyRemainsDisabled(@TempDir Path root) throws Exception { + Path world = existingWorld(root, "3.8.0.53"); + Path config = config(root, "B:PLACE_MINERALOGY_ROCK=false\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(GeologyMode.LEGACY, migrated.geologyMode()); + assertFalse(migrated.cyanoEnabled()); + assertReport(world, "Legacy Mineralogy geology was disabled and remains disabled", + "Geology enabled: false"); + } + + @Test + void hybridFileFrom112Uses112EnableFlagAndIgnores110Coal(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "3.8.0.53"); + Path config = config(root, + "B:PLACE_MINERALOGY_ROCK=false\nB:REALISTIC_COAL_LAYERS=true\n"); + + WorldGeologyProfile migrated = migrate(world, config); + JsonObject cyano = migrated.toJson().getAsJsonObject("cyano"); + + assertEquals("Mineralogy 1.12", cyano.get("legacy_lineage").getAsString()); + assertTrue(cyano.get("hybrid_config").getAsBoolean()); + assertFalse(migrated.cyanoEnabled()); + assertFalse(migrated.cyanoRealisticCoalLayers()); + assertReport(world, "Hybrid 1.10/1.12 keys found: true", + "Hybrid precedence: saved Mineralogy version"); + } + + @Test + void hybridFileCarriedFrom110Uses110CoalAndHasNoNonexistentEnableFlag(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "3.3.8.26"); + Path config = config(root, + "B:PLACE_MINERALOGY_ROCK=false\nB:REALISTIC_COAL_LAYERS=true\n"); + + WorldGeologyProfile migrated = migrate(world, config); + JsonObject cyano = migrated.toJson().getAsJsonObject("cyano"); + + assertEquals("Mineralogy 1.10", cyano.get("legacy_lineage").getAsString()); + assertTrue(migrated.cyanoEnabled()); + assertTrue(migrated.cyanoRealisticCoalLayers()); + assertTrue(strings(cyano, "sedimentary_rocks").contains("minecraft:coal_ore")); + } + + @Test + void configMarkersResolveAmbiguousSavedVersionWithoutBroadening(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, ""); + Path config = config(root, "B:REALISTIC_COAL_LAYERS=true\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals("Mineralogy 1.10", migrated.toJson().getAsJsonObject("cyano") + .get("legacy_lineage").getAsString()); + assertTrue(migrated.cyanoRealisticCoalLayers()); + } + + @Test + void existingWorldWithoutConfigUsesLineageSpecificPublishedDefaults(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "3.8.0.53"); + Path config = root.resolve("empty-config"); + Files.createDirectories(config); + + WorldGeologyProfile migrated = migrate(world, config); + + assertTrue(migrated.cyanoEnabled()); + assertEquals(100, migrated.cyanoGeomeSize()); + assertEquals(12, migrated.cyanoRockOrder("sedimentary_rocks").size()); + assertReport(world, "no; published Mineralogy 1.12 defaults used"); + } + + @Test + void freshOrNonLegacyWorldIsNeverReclassifiedFromInstalledConfig(@TempDir Path root) + throws Exception { + Path fresh = root.resolve("fresh"); + Files.createDirectories(fresh); + writeLevelDat(fresh, "mineralogy", "3.8.0.53"); + Path config = config(root, "B:PLACE_MINERALOGY_ROCK=true\n"); + assertNull(migrate(fresh, config)); + + Path modern = existingWorld(root.resolve("modern"), "6.0.0"); + assertNull(migrate(modern, config)); + Path unrelated = existingWorld(root.resolve("other"), "examplemod", "1.0.0"); + assertNull(migrate(unrelated, config)); + } + + @Test + void unreadableCurrentMetadataUsesLevelDatOldAndReportsIt(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "3.8.0.53"); + 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, "B:PLACE_MINERALOGY_ROCK=true\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals("level.dat_old", migrated.toJson().getAsJsonObject("cyano") + .get("legacy_metadata_source").getAsString()); + assertReport(world, "Saved mod metadata: level.dat_old"); + } + + @Test + void migrationAndHumanReportAreByteStable(@TempDir Path root) throws Exception { + Path world = existingWorld(root, "3.8.0.53"); + Path config = config(root, "B:PLACE_MINERALOGY_ROCK=true\nI:GEOME_SIZE=100\n"); + + WorldGeologyProfile first = migrate(world, config); + Path report = world.resolve("serverconfig/orespawn-upgrade-report.txt"); + byte[] firstReport = Files.readAllBytes(report); + WorldGeologyProfile second = migrate(world, config); + + assertEquals(first.toJson(), second.toJson()); + assertTrue(Arrays.equals(firstReport, Files.readAllBytes(report))); + } + + @Test + void snapshottedOrderPreservesDuplicatesAndOnlyFallsBackWhenEntireFamilyIsMissing() { + 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")); + root.add("cyano", cyano); + IBlockState[] resolved = Geology.resolveRockOrder(base.withRoot(root), + "sedimentary_rocks", new IBlockState[] { Blocks.BEDROCK.getDefaultState() }); + assertEquals(3, resolved.length); + assertEquals(Blocks.SANDSTONE, resolved[0].getBlock()); + assertEquals(Blocks.COAL_ORE, resolved[1].getBlock()); + assertEquals(Blocks.SANDSTONE, resolved[2].getBlock()); + + JsonObject missingRoot = base.rootCopy(); + JsonObject missingCyano = new JsonObject(); + missingCyano.add("igneous_rocks", array("missingmod:removed_rock")); + missingRoot.add("cyano", missingCyano); + IBlockState[] fallback = { Blocks.OBSIDIAN.getDefaultState() }; + assertEquals(Blocks.OBSIDIAN, Geology.resolveRockOrder(base.withRoot(missingRoot), + "igneous_rocks", fallback)[0].getBlock()); + } + + private static WorldGeologyProfile migrate(Path world, Path config) { + return LegacyMineralogyProfileMigration.migrateIfNeeded( + world, config, WorldGeologyProfile.recommended(false)); + } + + private static Path existingWorld(Path root, String version) throws IOException { + return existingWorld(root, "mineralogy", version); + } + + private static Path existingWorld(Path root, String modId, 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, modId, version); + return world; + } + + private static Path config(Path root, String contents) throws IOException { + Path config = root.resolve("config"); + Files.createDirectories(config); + Files.write(config.resolve("mineralogy.cfg"), contents.getBytes(StandardCharsets.UTF_8)); + return config; + } + + private static void writeLevelDat(Path world, String modId, String version) throws IOException { + NBTTagCompound root = new NBTTagCompound(); + NBTTagCompound fml = new NBTTagCompound(); + NBTTagList mods = new NBTTagList(); + NBTTagCompound mod = new NBTTagCompound(); + mod.setString("ModId", modId); + mod.setString("ModVersion", version); + mods.appendTag(mod); + fml.setTag("ModList", mods); + root.setTag("FML", fml); + try (FileOutputStream output = new FileOutputStream(world.resolve("level.dat").toFile())) { + CompressedStreamTools.writeCompressed(root, output); + } + } + + private static void assertReport(Path world, String... fragments) throws IOException { + String report = new String(Files.readAllBytes( + world.resolve("serverconfig/orespawn-upgrade-report.txt")), StandardCharsets.UTF_8); + for (String fragment : fragments) assertTrue(report.contains(fragment), fragment); + } + + private static 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 List list(String... values) { + return Arrays.asList(values); + } + + private static JsonArray array(String... values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String sha256(Path path) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] result = digest.digest(Files.readAllBytes(path)); + StringBuilder hex = new StringBuilder(); + for (byte value : result) hex.append(String.format("%02X", value)); + return hex.toString(); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigrationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigrationTest.java index 90a3513..9dfe798 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigrationTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigrationTest.java @@ -23,6 +23,8 @@ void writesOnceAndPreservesTheExactSecondLaunch() throws Exception { assertEquals(LegacyOs3ProfileMigration.Result.WRITTEN, LegacyOs3ProfileMigration.apply(temporary, true, true, true, true, true, false, 3)); Path profile = temporary.resolve("orespawn-worldgen.json"); byte[] first = Files.readAllBytes(profile); + Path report = temporary.resolve("orespawn-upgrade-report.txt"); + byte[] firstReport = Files.readAllBytes(report); JsonObject root = new JsonParser().parse(new String(first, StandardCharsets.UTF_8)).getAsJsonObject(); assertTrue(root.get("manage_vanilla_ores").getAsBoolean()); assertTrue(root.get("suppress_all_ore_features").getAsBoolean()); @@ -31,6 +33,11 @@ void writesOnceAndPreservesTheExactSecondLaunch() throws Exception { assertEquals(LegacyOs3ProfileMigration.Result.ALREADY_MIGRATED, LegacyOs3ProfileMigration.apply(temporary, false, false, false, false, false, false, 1)); assertArrayEquals(first, Files.readAllBytes(profile)); + assertArrayEquals(firstReport, Files.readAllBytes(report)); + String humanReport = new String(firstReport, StandardCharsets.UTF_8); + assertTrue(humanReport.contains("Legacy OreSpawn settings were imported")); + assertTrue(humanReport.contains("Manage vanilla ores: true")); + assertTrue(humanReport.contains("Original legacy configuration files are retained unchanged")); } @Test