diff --git a/CHANGELOG.txt b/CHANGELOG.txt index f72e9673..57f25900 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,18 @@ +Version 4.0.6.2601022 + +* Adopt target-qualified four-component versions so Minecraft and loader compatibility can be identified from the mod version. +* Preserve generated worlds made with Mineralogy 1.10, 1.12, or 5.x by reading their saved mod metadata and exact legacy configuration before creating the OreSpawn world profile. +* Write human-readable, idempotent upgrade reports for legacy OreSpawn and Mineralogy imports while retaining source files and existing chunks unchanged. +* Audit automated runtime logs and dynamic-fluid generation so logged worldgen failures cannot pass merely because the process exits normally. +* Qualify existing OreSpawn 4.0.5 global and per-world profiles without changing explicit Custom values or provider definitions. +* Fix provider top and filler materials being generated one block below exposed ground. +* Apply underwater materials from the corrected ground and ceiling materials to roof undersides. +* Preserve trees, vegetation, structures and block entities by running surface replacement before late features. +* Honour exact biome-to-geome weights on dynamic biome registries. +* Stagger close Stable Layers geome transitions by layer instead of changing a whole rock column at one boundary. +* Recalibrate Stable Layers edge-detail presets so Average retains natural variation at later rock contacts. +* Existing chunks are not rewritten; the correction applies while generating new chunks. + Version 3.3.1 * Fix several bugs diff --git a/README.md b/README.md index ce287e66..2b3dc24d 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,22 @@ Important files: | `/serverconfig/orespawn-worldgen.json` | Complete settings snapshot for one world | | `config/-orespawn.json` | Optional modpack override for one provider | | `config/orespawn-guide/README.md` | Guide exported automatically on first load | +| `config/orespawn-upgrade-report.txt` | Human summary produced when legacy OreSpawn rules are imported | +| `/serverconfig/orespawn-upgrade-report.txt` | Human summary produced when a generated legacy Mineralogy world is pinned to its saved settings | Profile edits affect newly generated chunks. Ore and flat-bedrock retrogen are separate opt-in features; OreSpawn never retro-generates rock strata. +Stable Layers honours exact biome-ID geome influences on dynamic biome +registries and spreads close geome transitions across layers rather than +changing an entire vertical rock column at one boundary. + +When an already-generated world has saved Mineralogy 1.10, 1.12, or 5.x mod +metadata but no OreSpawn world profile, OreSpawn reads the matching published +configuration contract and records the exact engine, numeric settings, rock +order, and white/blacklists in the new world profile. Saved-world identity +wins over stale files in the installation. A fresh world is never reclassified +merely because an old `mineralogy.cfg` or `mineralogy-common.toml` remains in +the instance. To move a configured single-player world to a dedicated server, copy the world's `serverconfig/orespawn-worldgen.json` with the world and install the @@ -84,9 +97,12 @@ Use Java 25 from the repository root: ``` `build` runs the standard `check` lifecycle. In addition to the JUnit suite, -that lifecycle packages a test-only provider mod, loads its custom biome in -normal noise terrain, and verifies both fresh generation and reopening the -same saved world. The fixture is not included in OreSpawn's published jars. +that lifecycle packages a test-only provider mod and verifies exposed, +underwater, filler, and ceiling surfaces in open and ceiling normal-noise +dimensions. It also proves later vegetation, structures, and block entities +survive, verifies identifier-weighted geology in a dynamic custom biome, then +reopens and checks the exact saved world. The fixture is not included in +OreSpawn's published jars. Import or refresh the project with Eclipse Buildship. NeoGradle supplies the Eclipse model and run configurations through the `eclipse` task; this branch diff --git a/build.gradle b/build.gradle index f9cede30..98a971c7 100644 --- a/build.gradle +++ b/build.gradle @@ -108,12 +108,12 @@ runs { } ['Fresh', 'Reload'].each { String phase -> - register("biomeIntegration${phase}") { + register("surfaceIntegration${phase}") { runType 'gameTestServer' - workingDirectory layout.buildDirectory.dir('biome-integration-run') - systemProperty 'neoforge.enabledGameTestNamespaces', 'cakeworldprobe' + workingDirectory layout.buildDirectory.dir('surface-integration-run') + systemProperty 'neoforge.enabledGameTestNamespaces', 'surfaceprobe' systemProperty 'forge.logging.console.level', 'info' - systemProperty 'cakeworld.biomeIntegrationPhase', phase.toLowerCase(Locale.ROOT) + systemProperty 'surfaceprobe.integrationPhase', phase.toLowerCase(Locale.ROOT) modSource project.sourceSets.main } } @@ -201,79 +201,249 @@ tasks.withType(JavaCompile).configureEach { tasks.named('javadoc', Javadoc).configure { options.encoding = 'UTF-8' options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('-no-fonts', true) } tasks.named('test', Test).configure { useJUnitPlatform() + // Unit tests inspect target files relative to the checkout, but they do + // not need NeoForge's rolling runtime files. A console-only logger keeps + // them from contending with Eclipse/client logs in this working directory. + systemProperty 'log4j.configurationFile', file('src/test/resources/log4j2-test.xml').absolutePath + // Loaded only through an isolated URLClassLoader by the parity test. This + // is deliberately not a Gradle dependency and cannot leak into Eclipse or + // a published OreSpawn jar. + File mineralogy5Oracle = file('../../MinecraftMineralogy 118/MinecraftMineralogy/build/libs/Mineralogy-1.18.2-5.4.0.jar') + if (mineralogy5Oracle.isFile()) { + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5Oracle.absolutePath + } +} + +// Several registry-focused tests initialize the real global config singleton. +// Keep that target-native coverage without creating or changing a developer's +// checkout config as a side effect of `test` or `build`. +def unitTestWorldgenConfig = file('config/orespawn-worldgen.json') +def unitTestWorldgenConfigWasPresent = false +byte[] unitTestWorldgenConfigBytes = null +tasks.named('test', Test).configure { + doFirst { + unitTestWorldgenConfigWasPresent = unitTestWorldgenConfig.isFile() + unitTestWorldgenConfigBytes = unitTestWorldgenConfigWasPresent + ? unitTestWorldgenConfig.bytes : null + } +} +def preserveDeveloperWorldgenConfig = tasks.register('preserveDeveloperWorldgenConfig') { + doLast { + if (unitTestWorldgenConfigWasPresent) { + byte[] after = unitTestWorldgenConfig.isFile() ? unitTestWorldgenConfig.bytes : null + if (after == null || !java.util.Arrays.equals(unitTestWorldgenConfigBytes, after)) { + unitTestWorldgenConfig.parentFile.mkdirs() + unitTestWorldgenConfig.bytes = unitTestWorldgenConfigBytes + throw new GradleException('Unit tests changed config/orespawn-worldgen.json; the original was restored') + } + } else if (unitTestWorldgenConfig.isFile()) { + delete unitTestWorldgenConfig + } + } +} +tasks.named('test') { + finalizedBy preserveDeveloperWorldgenConfig +} + +// A NeoForge process is not green merely because it returns exit code zero. +// The loader can log a worldgen/linkage failure and still shut down normally. +// No ERROR/FATAL line has been accepted as harmless on NeoForge 26.1.2; add an +// exception only after reproducing it in the matching loader control. +def acceptedNeoForge2612LogNoise = [] + +def runtimeCrashSnapshot = { File runDirectory -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (!crashDirectory.isDirectory()) return [] as Set + return fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() }.collect { it.absolutePath } as Set +} + +def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (crashDirectory.isDirectory()) { + def crashes = fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } + if (!crashes.isEmpty()) { + throw new GradleException("${context} produced crash report ${crashes.first()}") + } + } + + File logsDirectory = new File(runDirectory, 'logs') + if (!logsDirectory.isDirectory()) return + def failures = [] + [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] + .findAll { it.isFile() }.each { File log -> + int lineNumber = 0 + log.eachLine('UTF-8') { String line -> + lineNumber++ + boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ + boolean knownNoise = acceptedNeoForge2612LogNoise.any { line =~ it } + boolean fatalText = line.contains('Encountered an unexpected exception') || + line.contains('Exception stopping the server') || + line.contains('Migration audit failed') || + line.contains('java.lang.Error:') || + line.contains('NoSuchMethodError') || + line.contains('NoClassDefFoundError') || + line.contains('ExceptionInInitializerError') || + line.contains('Tried to assign a mutable BlockPos') || + line.contains('causing cascading worldgen lag') + if ((unexpectedSeverity && !knownNoise) || fatalText) { + failures.add("${log.name}:${lineNumber}: ${line}") + } + } + } + if (!failures.isEmpty()) { + throw new GradleException("${context} logged unexpected errors:\n" + + failures.take(20).join('\n')) + } +} + +tasks.register('runtimeLogScannerTest') { + group = 'verification' + description = 'Proves runtime log validation rejects real NeoForge 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( + '[Server thread/INFO] [neoforge]: Done\n', 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-clean-log-probe', [] as Set) + new File(logs, 'latest.log').setText( + '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') + boolean rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') + new File(logs, 'latest.log').setText( + '[Server thread/DEBUG] [neoforge]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') + new File(logs, 'latest.log').setText( + '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') + delete probe + } +} + +tasks.named('check') { + dependsOn 'runtimeLogScannerTest' +} + +tasks.register('verifyMineralogyOracleIsolation') { + group = 'verification' + description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' + doLast { + configurations.each { configuration -> + if (configuration.canBeResolved && + configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { + throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") + } + } + } +} + +tasks.named('check') { + dependsOn 'verifyMineralogyOracleIsolation' +} + +['runClient', 'runServer', 'runGameTestServer', 'runClientData'].each { String taskName -> + tasks.matching { it.name == taskName }.configureEach { JavaExec runTask -> + doFirst { + new File(runTask.workingDir, 'mods').mkdirs() + runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) + } + doLast { + assertRuntimeLogsClean(runTask.workingDir, taskName, + runTask.ext.oreSpawnCrashSnapshot as Set) + } + } } -def biomeIntegrationClasses = layout.buildDirectory.dir('biome-integration-fixture/classes') -def compileBiomeIntegrationTestMod = tasks.register('compileBiomeIntegrationTestMod', JavaCompile) { +def surfaceIntegrationClasses = layout.buildDirectory.dir('surface-integration-fixture/classes') +def compileSurfaceIntegrationTestMod = tasks.register('compileSurfaceIntegrationTestMod', JavaCompile) { dependsOn tasks.named('classes') source fileTree('src/biomeIntegrationTest/java') classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory.set(biomeIntegrationClasses) + destinationDirectory.set(surfaceIntegrationClasses) javaCompiler.set(javaToolchains.compilerFor { languageVersion = JavaLanguageVersion.of(25) }) - options.release = 16 + options.release = 25 options.encoding = 'UTF-8' } -def biomeIntegrationTestModJar = tasks.register('biomeIntegrationTestModJar', Jar) { - dependsOn compileBiomeIntegrationTestMod - archiveFileName = 'cakeworldprobe.jar' - destinationDirectory = layout.buildDirectory.dir('biome-integration-fixture') +def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { + dependsOn compileSurfaceIntegrationTestMod + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') manifest { - attributes 'MixinConfigs': 'cakeworldprobe.mixins.json' + attributes 'MixinConfigs': 'surfaceprobe.mixins.json' } - from biomeIntegrationClasses + from surfaceIntegrationClasses from 'src/biomeIntegrationTest/resources' } -def biomeIntegrationRunDirectory = layout.buildDirectory.dir('biome-integration-run') -def prepareBiomeIntegrationTest = tasks.register('prepareBiomeIntegrationTest') { - dependsOn biomeIntegrationTestModJar +def surfaceIntegrationRunDirectory = layout.buildDirectory.dir('surface-integration-run') +def prepareSurfaceIntegrationTest = tasks.register('prepareSurfaceIntegrationTest') { + dependsOn surfaceIntegrationTestModJar doLast { - delete biomeIntegrationRunDirectory + delete surfaceIntegrationRunDirectory copy { - from biomeIntegrationTestModJar.flatMap { it.archiveFile } - into biomeIntegrationRunDirectory.map { it.dir('mods') } + from surfaceIntegrationTestModJar.flatMap { it.archiveFile } + into surfaceIntegrationRunDirectory.map { it.dir('mods') } } } } tasks.configureEach { - if (name == 'runBiomeIntegrationFresh') { - dependsOn prepareBiomeIntegrationTest - } else if (name == 'runBiomeIntegrationReload') { - dependsOn 'runBiomeIntegrationFresh' + if (name == 'runSurfaceIntegrationFresh') { + dependsOn prepareSurfaceIntegrationTest + } else if (name == 'runSurfaceIntegrationReload') { + dependsOn 'runSurfaceIntegrationFresh' + } + if (name == 'runSurfaceIntegrationFresh' || name == 'runSurfaceIntegrationReload') { + doFirst { + ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(workingDir) + } + doLast { + assertRuntimeLogsClean(workingDir, "NeoForge 26.1.2 ${name}", + ext.oreSpawnCrashSnapshot as Set) + } } } -def biomeIntegrationTest = tasks.register('biomeIntegrationTest') { +def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { group = 'verification' - description = 'Verifies a provider-owned custom biome in fresh and reloaded normal terrain.' - dependsOn 'runBiomeIntegrationReload' + description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' + dependsOn 'runSurfaceIntegrationReload' doLast { - File marker = biomeIntegrationRunDirectory.get().file( - 'gametestserver/gametestworld/cakeworld-biome-integration.properties').asFile + File marker = surfaceIntegrationRunDirectory.get().file( + 'gametestserver/gametestworld/surfaceprobe-integration.properties').asFile if (!marker.isFile()) { - throw new GradleException("Biome integration completion marker is missing: ${marker}") + throw new GradleException("Surface integration completion marker is missing: ${marker}") } Properties result = new Properties() marker.withInputStream { result.load(it) } if (result.getProperty('reload_verified') != 'true') { - throw new GradleException("Biome integration reload was not verified: ${marker}") + throw new GradleException("Surface integration reload was not verified: ${marker}") } - logger.lifecycle('Custom-biome integration verified: {} chunks, {} top blocks, {} filler blocks, fresh + reload', - result.getProperty('matching_chunks'), result.getProperty('pink_surface'), - result.getProperty('white_filler')) + logger.lifecycle('Provider surfaces and dynamic-biome geology verified: {} dimensions, {} columns each, fresh + reload', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) } } tasks.named('check') { - dependsOn biomeIntegrationTest + dependsOn surfaceIntegrationTest } idea { diff --git a/docs/AGENTS.md b/docs/AGENTS.md index bcf83474..8adc7353 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,7 +1,8 @@ -# OreSpawn Documentation For Coding Agents +# OreSpawn Documentation Map -This index is for coding agents working on other mods or modpacks that integrate -with OreSpawn. Start with [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). +This index is for navigating the documentation to learn how to integrate with +and use OreSpawn with a mod or modpack. +Start with [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). Use the focused guides for implementation details: @@ -11,4 +12,6 @@ Use the focused guides for implementation details: - [BIOMES.md](BIOMES.md) and [DIMENSIONS.md](DIMENSIONS.md) for world integration; - [TEMPLATES.md](TEMPLATES.md) for selectable world styles; - [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; +- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified + versioning and branch-release convention; - [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/API.md b/docs/API.md index 105c89c6..293cb554 100644 --- a/docs/API.md +++ b/docs/API.md @@ -133,9 +133,11 @@ OreSpawnApi.createSampler(server.overworld()).ifPresent(sampler -> { }); ``` -`sampleColumn` performs one biome/geome classification and reuses it for every -Y query. Sampling is read-only and is intended for gameplay decisions, -diagnostics, and compatible generation outside OreSpawn's block loops. +`sampleColumn` performs one biome/dominant-geome classification and reuses its +transition scores for every Y query. `rockAt` therefore matches Stable Layers +when a close geome transition is staggered by layer. Sampling is read-only and +is intended for gameplay decisions, diagnostics, and compatible generation +outside OreSpawn's block loops. Callbacks inside OreSpawn generation loops are intentionally unsupported. Custom pattern mods create a NeoForge `DeferredRegister` using diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 18843649..1ea32b2a 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -126,6 +126,16 @@ Biome surfaces support: - `ceiling_block`: optional underside material; - `filler_depth`: 0-16 blocks. +Provider surfaces run during `LOCAL_MODIFICATIONS`: after Minecraft has built +base surfaces and lakes, but before structures and vegetation. That ordering +lets OreSpawn replace the actual exposed ground while preserving later trees, +plants, authored structures, and block entities. In ceiling dimensions, +`ceiling_block` applies to the roof underside and does not replace the roof top. + +Surface correction is generation-only. Installing or updating OreSpawn does +not rewrite already generated chunks; travel into new terrain to see a changed +provider surface definition. + Dimension materials support the ordinary aquifer fluid, a deep aquifer fluid and threshold, and replacements for vanilla snow and ice. OreSpawn converts weather products in loaded chunks and around players; it does not replace every diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8f40c6e0..c342e1a9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -17,6 +17,14 @@ packaged or API providers, provider override files, the global configuration, the selected template, and Create World edits. The result is saved with the world. Restart after editing JSON by hand. +An existing generated world follows a stricter safety order. Its existing +`serverconfig/orespawn-worldgen.json` always wins. If none exists, saved legacy +Mineralogy mod metadata may select the matching 1.10, 1.12, or 5.x config +contract before the world profile is first written. Installed-pack defaults, +Create World choices, and unrelated stale legacy files cannot override that +saved-world identity. See `MIGRATION.md` and the generated per-world upgrade +report for the exact decision. + ## Top-Level Fields | Field | Values | Meaning | @@ -75,10 +83,31 @@ When a control is `custom`, its value comes from `formations.custom`: | `edge_octaves` | 1-8 | Number of boundary-detail scales | | `continuity` | 0-1 | Proportion of formations retaining global identity | +For Stable Layers, the Edge Detail presets use these +`wavelength / amplitude / octaves` values: + +| Preset | Edge detail | +|---|---:| +| Tiny | `48 / 4 / 1` | +| Small | `64 / 12 / 2` | +| Average | `96 / 24 / 3` | +| Large | `128 / 48 / 4` | +| Huge | `192 / 96 / 5` | + +Average is calibrated to retain visible variation at later layer contacts. +Custom profiles keep their explicit values; these numbers are only used by the +named presets and as defaults for new Custom settings. + Cyano settings use `cyano.geome_size` (4-32767), `cyano.rock_layer_noise` (1-32767), and `cyano.rock_layer_thickness` (1-255). They are ignored by Sky. +Profiles created from a legacy Mineralogy world also retain +`cyano.enabled`, `cyano.realistic_coal_layers`, the three effective +`*_rocks` arrays, the six original `*_whitelist`/`*_blacklist` arrays, and +source/version fields. These are migration snapshots, not new settings that a +fresh pack needs to author. + ## Rocks And Geomes A rock requires `enabled`, `family`, `depth_peak`, `depth_spread`, `min_y`, @@ -90,6 +119,10 @@ weight by province. A weight of zero prevents selection in that context. Geomes contain a non-negative `base` weight and non-negative weights for each rock family. Biome and biome-dictionary maps multiply those geome weights. Missing optional-mod biome IDs are ignored during baking. +Exact biome-ID maps remain effective when the target uses a dynamic biome +registry. With Stable Layers, a close contest between two geomes transitions +at a deterministic position per layer so the whole underground column does +not change on one sheer plane. Terrain dimensions require `enabled`, `host_blocks`, and `host_tags`. `biome_ids` and `biome_namespaces` can narrow a custom dimension. The Overworld diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index d2610597..7f0b50ad 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -198,10 +198,11 @@ bounded ore or bedrock retrogen is enabled. 6. Confirm the provider appears in `/orespawn status`. 7. Test a new world; profile edits do not rewrite already generated terrain. -OreSpawn's own standard `check` lifecycle includes a consumer-style biome -integration test. It loads a separate test provider and datapack biome, proves -the provider is active, verifies biome selection, climate and configured -surface blocks in non-flat terrain, then reopens and rechecks the same saved -world. Run `gradlew check` (or `gradlew build`, which includes it) before -publishing any change to biome registration, palettes, surfaces or profile -persistence. +OreSpawn's own standard `check` lifecycle includes a consumer-style surface +integration test. A separate test provider creates independently marked +Grass/Dirt, underwater, filler, and roof columns in open and ceiling +normal-noise dimensions. The gate verifies biome and chunk edges, late tree, +vegetation, structure and chest sentinels, the roof underside, and exact save +reload behavior. Run `gradlew check` (or `gradlew build`, which includes it) +before publishing any change to biome registration, palettes, surfaces, +feature ordering, height handling, or profile persistence. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 631deaab..92846911 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -13,6 +13,37 @@ legacy-ID behaviour. Migration is non-destructive. OreSpawn writes `config/orespawn-worldgen.json` only when that target does not already exist and retains every source file. +When legacy OreSpawn rules are translated, a concise player-facing summary is +also written atomically to `config/orespawn-upgrade-report.txt`; the existing +detailed rule report remains at `config/orespawn-migration/migration-report.txt`. + +## Existing Mineralogy Worlds + +An already-generated world without an OreSpawn per-world profile is inspected +before OreSpawn chooses any installed-pack or Create World default. OreSpawn +uses saved mod metadata from `level.dat`, with a valid `level.dat_old` as a +fallback, to distinguish these published contracts: + +- Mineralogy 1.10.2 `3.3.8.26` and its `mineralogy.cfg`; +- Mineralogy 1.12.2 `3.8.0.53` and its distinct `mineralogy.cfg`; +- Mineralogy 5.0.1 through 5.4.0 and `mineralogy-common.toml`. + +The resulting world profile preserves enablement, selected legacy/geome +engine, geome size, layer noise and thickness, realistic-coal behavior where +supported, exact effective rock order (including historical duplicates), and +all six white/blacklists. Saved-world identity chooses the lineage even when a +different stale config is present. Missing or malformed values use that +lineage's published defaults and are reported rather than silently broadening +the world configuration. + +The human-readable result is written atomically to +`/serverconfig/orespawn-upgrade-report.txt`. It identifies the saved +version and metadata source, config source, selected engine and lineage, +effective settings and outputs, missing IDs, fallbacks, and warnings. Source +configuration and existing chunks are not rewritten. Once +`orespawn-worldgen.json` exists it is authoritative and the import is not run +again. A fresh world containing stale legacy files remains on its explicit +OreSpawn/Create World settings. When `config/mineralogy-geomes.json` exists, OreSpawn imports the Mineralogy 6 profile directly, updates its schema marker, and records `migrated_from`. diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index f18e6e76..08a587f4 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -93,3 +93,20 @@ the same mods. Alternatively, place a prepared global profile at The server console commands `/orespawn status`, `/orespawn reload`, and `/orespawn dump-biomes` help pack authors diagnose active providers and IDs. + +### Upgrading a Mineralogy world + +If the world was already generated with Mineralogy 1.10, 1.12, or 5.x and has +no OreSpawn world profile yet, OreSpawn reads the Mineralogy version saved in +the world and the matching old configuration. It preserves the selected +engine, numeric settings, rock order, and lists rather than silently applying +new-world defaults. Look for: + +```text +/serverconfig/orespawn-upgrade-report.txt +``` + +The report explains what was detected and retained, including any missing rock +IDs or fallback values. OreSpawn leaves the old configuration and generated +chunks untouched. A fresh world does not inherit this behavior merely because +an old Mineralogy config is still present in the instance. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md new file mode 100644 index 00000000..f43b1c15 --- /dev/null +++ b/docs/VERSIONS.md @@ -0,0 +1,203 @@ +# Mod Versioning Policy + +This document defines how versions are assigned to MMD mods and how an exact +Minecraft and loader target is encoded in a release version. + +## Version format + +Mod versions use four numeric components: + +```text +Major.Minor.Bug.Target +``` + +The first three components describe the functional release. For example, +OreSpawn `4.0.6` means major version 4, minor version 0, and bug revision 6. + +The fourth component identifies the Minecraft and loader target. A complete +release version such as `4.0.6.120061` therefore identifies both the OreSpawn +4.0.6 feature set and its Minecraft 1.20.6 Forge build. + +This is an expanded, Maven-compatible versioning convention. It is not strict +Semantic Versioning 2.0, which defines exactly three numeric core components. + +When the Major or Minor component increases, the functional components to its +right reset to zero. The Target component is then appended for the build being +released. For example: + +```text +4.0.6.120061 -> 4.1.0.120061 +4.1.3.120061 -> 5.0.0.120061 +``` + +## Target component + +The Target component is deterministic and is not another feature or bug +sequence number. + +To calculate it: + +1. Normalize the Minecraft version to `major.minor.patch`, using zero when the + patch component is omitted. +2. Concatenate the Minecraft major number without padding, the minor number as + two digits, the patch number as two digits, and the one-digit loader code. +3. Use loader code `1` for Forge and `2` for NeoForge. + +The component can be decoded from right to left: one loader digit, two patch +digits, two minor digits, and all remaining digits for the Minecraft major +version. + +Examples: + +| Minecraft | Loader | Target | Full OreSpawn 4.0.6 version | +| --- | --- | ---: | --- | +| 1.13.2 | Forge | `113021` | `4.0.6.113021` | +| 1.20.6 | Forge | `120061` | `4.0.6.120061` | +| 1.21.11 | Forge | `121111` | `4.0.6.121111` | +| 26.1.2 | Forge | `2601021` | `4.0.6.2601021` | +| 26.1.2 | NeoForge | `2601022` | `4.0.6.2601022` | +| 26.2 | Forge | `2602001` | `4.0.6.2602001` | +| 26.2 | NeoForge | `2602002` | `4.0.6.2602002` | + +Historical MMD releases may also have four numeric components but may have used +the fourth component differently. This policy applies prospectively; it does +not reinterpret an old release number. + +## Major version + +Increase the **Major** number for a large-scale change, paradigm shift, or +breaking change that moves the mod forward in a fundamental way. + +Examples include: + +- Mineralogy 6 no longer containing its own world generation engine, unlike + Mineralogy 5. +- OreSpawn 4 gaining a complete terrain generation engine, including strata, + unlike OreSpawn 3. + +Compatibility adaptations required to support another Minecraft or loader +version do not by themselves require a major version increase when the mod's +supported behaviour and public contracts remain equivalent. + +## Minor version + +Increase the **Minor** number for a new feature or a significant change to +existing behaviour that does not justify a new major generation. + +Examples include: + +- adding a new player-usable block or other substantial feature; +- substantially overhauling a world-generation engine; +- making a significant fix or adjustment that materially changes how a major + part of the mod behaves. + +## Bug version + +Increase the **Bug** number for a bug fix or a very small feature that does not +materially change the mod's design. + +Examples include: + +- correcting a generation defect; +- fixing a user interface or compatibility problem; +- adding or correcting a language file translation; +- making a small documentation or configuration improvement that warrants a + release. + +This component is sometimes called the patch number in other versioning +systems. MMD uses the name **Bug** to make its intended purpose explicit. + +## Ports to new Minecraft versions + +Porting a mod to a new Minecraft version does not automatically change the +functional `Major.Minor.Bug` version. Functionally equivalent ports share those +first three components, while their complete versions have different Target +components. + +For example: + +```text +Minecraft 26.1.2 / Forge / OreSpawn 4.0.6.2601021 +Minecraft 26.1.2 / NeoForge / OreSpawn 4.0.6.2601022 +Minecraft 26.2 / Forge / OreSpawn 4.0.6.2602001 +Minecraft 26.2 / NeoForge / OreSpawn 4.0.6.2602002 +``` + +Target-specific implementation details may differ internally where Minecraft +or its mod loader requires them. Those adaptations do not change the functional +version when users and integrations receive the same supported behaviour. + +If a port also introduces a feature or fix that changes the functional release, +the first three components must be assessed using the Major, Minor, and Bug +rules above. The Target component always identifies the build's actual +Minecraft and loader target. + +## Branch-specific fixes and skipped numbers + +Functional version numbers are allocated across the mod as a whole and must +not be reused for unrelated change sets on different Minecraft branches. The +same `Major.Minor.Bug` may be shared by functionally equivalent ports. + +If a released branch receives a bug fix that other branches do not require, +only the affected branch's Bug number is incremented. For example, Forge +1.13.2 may move from `4.0.6.113021` to `4.0.7.113021` while unaffected branches +remain on their target-qualified 4.0.6 versions. + +If a different branch later receives a separate fix, it uses the next unused +Bug number, such as `4.0.8`, even if the `4.0.7` fix was not applicable to it. +A branch may therefore legitimately skip functional version numbers. + +This provides three useful guarantees: + +1. A functional version is not used to describe two unrelated change sets. +2. A higher functional version identifies a later change in the mod's release + history. +3. The Target component identifies the exact Minecraft and loader build without + overloading the functional version. + +A higher functional version on another Minecraft branch does **not** +necessarily mean it contains every lower-numbered branch-specific fix. Some +fixes are relevant only to a particular Minecraft or loader implementation. + +## Dependency ranges + +Dependencies should normally express the compatible functional release range. +For example, Maven-style range `[4.0.6,5.0.0)` deliberately accepts all +target-qualified OreSpawn 4.0.6 builds while excluding OreSpawn 5. + +Consumers must still declare their supported Minecraft version and loader in +their own metadata. The Target component makes that compatibility visible; it +does not replace loader-level compatibility checks. + +## Release and pull-request documentation + +Because maintained branches can legitimately contain different fixes, the +version number alone is not a substitute for release notes. + +Every release and pull request should state: + +- the Minecraft version and loader it targets; +- the complete four-component version and its functional `Major.Minor.Bug`; +- the features and fixes actually included; +- any fixes from nearby versions that are not applicable to that branch; +- whether the change is functionally equivalent to another maintained branch; +- any migration, compatibility, or configuration considerations for users. + +## Decision summary + +When assigning a version, ask the following questions in order: + +1. Is this a fundamental or breaking new generation of the mod? Increase + **Major**. +2. Is this a substantial feature or significant behavioural overhaul? Increase + **Minor**. +3. Is this a bug fix or very small feature? Increase **Bug**, using the next + unused number across the mod. +4. Is this only a functionally equivalent Minecraft or loader port? Keep the + existing `Major.Minor.Bug`. +5. Calculate and append the Target component for the exact Minecraft and loader + build. + +The objective is to make versions useful to players, pack developers, mod +integrators, release automation, and support teams while allowing each +maintained Minecraft branch to receive only the changes it actually needs. diff --git a/gradle.properties b/gradle.properties index 0243452c..39949830 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,7 +30,7 @@ mod_name=MMD OreSpawn # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-2.1 # The mod version. See https://semver.org/ -mod_version=4.0.5 +mod_version=4.0.6.2601022 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/CakeWorldBiomeIntegrationTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/CakeWorldBiomeIntegrationTestMod.java deleted file mode 100644 index edeb23e3..00000000 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/CakeWorldBiomeIntegrationTestMod.java +++ /dev/null @@ -1,232 +0,0 @@ -package zone.moddev.mc.orespawn.testmod; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Properties; - -import zone.moddev.mc.orespawn.api.BiomePlacementMode; -import zone.moddev.mc.orespawn.api.BiomeRegionSize; -import zone.moddev.mc.orespawn.api.BiomeReplacementScope; -import zone.moddev.mc.orespawn.api.OreSpawnApi; -import zone.moddev.mc.orespawn.api.ProviderStatus; -import zone.moddev.mc.orespawn.api.WorldgenProvider; -import zone.moddev.mc.orespawn.api.WorldgenProvider.BiomeSurfaceDefinition; - -import net.minecraft.core.BlockPos; -import net.minecraft.resources.Identifier; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.chunk.LevelChunk; -import net.minecraft.world.level.chunk.status.ChunkStatus; -import net.minecraft.world.level.levelgen.FlatLevelSource; -import net.minecraft.world.level.levelgen.Heightmap; -import net.minecraft.world.level.storage.LevelResource; -import net.neoforged.bus.api.IEventBus; -import net.neoforged.fml.common.Mod; -import net.neoforged.fml.event.lifecycle.InterModEnqueueEvent; -import net.neoforged.neoforge.common.NeoForge; -import net.neoforged.neoforge.event.server.ServerStartedEvent; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Test-only provider mod which exercises the same custom-biome path used by - * CakeWorld. This source set is excluded from every published OreSpawn jar. - */ -@Mod(CakeWorldBiomeIntegrationTestMod.MODID) -public final class CakeWorldBiomeIntegrationTestMod { - static final String MODID = "cakeworldprobe"; - - private static final Logger LOGGER = LogManager.getLogger(); - private static final Identifier DIMENSION = Identifier.parse("minecraft:the_nether"); - private static final Identifier BIOME = Identifier.parse(MODID + ":cake_plains"); - private static final Identifier PINK_CONCRETE = Identifier.parse("minecraft:pink_concrete"); - private static final Identifier WHITE_CONCRETE = Identifier.parse("minecraft:white_concrete"); - private static final int MINIMUM_CHUNK = 63; - private static final int MAXIMUM_CHUNK = 65; - private static final String PHASE_PROPERTY = "cakeworld.biomeIntegrationPhase"; - private static final String MARKER_NAME = "cakeworld-biome-integration.properties"; - - public CakeWorldBiomeIntegrationTestMod(IEventBus modBus) { - modBus.addListener(this::enqueueProvider); - NeoForge.EVENT_BUS.addListener(this::auditGeneratedBiome); - } - - private void enqueueProvider(InterModEnqueueEvent event) { - BiomeSurfaceDefinition surface = BiomeSurfaceDefinition.builder() - .topBlock(PINK_CONCRETE) - .fillerBlock(WHITE_CONCRETE) - .fillerDepth(3) - .build(); - WorldgenProvider provider = WorldgenProvider.builder(MODID, 1) - .biomePalette(Identifier.parse(MODID + ":normal_terrain"), DIMENSION, - palette -> palette - .mode(BiomePlacementMode.REPLACE) - .scope(BiomeReplacementScope.MINECRAFT_ONLY) - .regionSize(BiomeRegionSize.TINY) - .coverage(1.0D) - .fallbackWeight(0.0D) - .biome(BIOME, biome -> biome - .weight(1.0D) - .temperature(-2.0D, 2.0D) - .downfall(0.0D, 1.0D) - .surface(surface))) - .build(); - if (!OreSpawnApi.enqueue(provider)) { - throw new IllegalStateException("Could not enqueue CakeWorld biome integration provider"); - } - } - - private void auditGeneratedBiome(ServerStartedEvent event) { - String phase = System.getProperty(PHASE_PROPERTY, "").trim(); - if (!phase.equals("fresh") && !phase.equals("reload")) { - throw new IllegalStateException("Missing or invalid " + PHASE_PROPERTY + ": " + phase); - } - if (OreSpawnApi.getProviderStatus(MODID) != ProviderStatus.ACTIVE) { - throw new IllegalStateException("CakeWorld biome integration provider is not active"); - } - - ServerLevel level = event.getServer().getLevel(Level.NETHER); - if (level == null) { - throw new IllegalStateException("Biome integration dimension is unavailable: " + DIMENSION); - } - if (level.getChunkSource().getGenerator() instanceof FlatLevelSource) { - throw new IllegalStateException("Biome integration test requires normal noise terrain"); - } - - Path marker = event.getServer().getWorldPath(LevelResource.ROOT).resolve(MARKER_NAME); - Properties previous = phase.equals("reload") ? readMarker(marker) : null; - if (phase.equals("fresh") && Files.exists(marker)) { - throw new IllegalStateException("Fresh biome integration world retained a reload marker"); - } - - AuditResult result = auditChunks(level); - if (previous != null) { - assertReloadValue(previous, "seed", level.getSeed()); - assertReloadValue(previous, "matching_chunks", result.matchingChunks()); - assertReloadValue(previous, "pink_surface", result.pinkSurface()); - assertReloadValue(previous, "white_filler", result.whiteFiller()); - previous.setProperty("reload_verified", "true"); - writeMarker(marker, previous); - } else { - writeMarker(marker, level.getSeed(), result); - } - - LOGGER.info("CAKEWORLD_BIOME_INTEGRATION PASS phase={} biome={} chunks={} " - + "pink_surface={} white_filler={} temperature={} downfall={}", - phase, BIOME, result.matchingChunks(), result.pinkSurface(), result.whiteFiller(), - result.temperature(), result.downfall()); - } - - private static AuditResult auditChunks(ServerLevel level) { - int matchingChunks = 0; - long pinkSurface = 0L; - long whiteFiller = 0L; - float temperature = Float.NaN; - float downfall = Float.NaN; - BlockPos.MutableBlockPos center = new BlockPos.MutableBlockPos(); - BlockPos.MutableBlockPos block = new BlockPos.MutableBlockPos(); - - for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { - for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { - level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, true); - LevelChunk chunk = level.getChunk(chunkX, chunkZ); - center.set((chunkX << 4) + 8, level.getSeaLevel(), (chunkZ << 4) + 8); - var biome = level.getBiome(center); - Identifier actual = biome.unwrapKey().map(key -> key.identifier()).orElse(null); - if (!BIOME.equals(actual)) { - throw new IllegalStateException("Expected " + BIOME + " at chunk " - + chunkX + "," + chunkZ + " but found " + actual); - } - matchingChunks++; - if (Float.isNaN(temperature)) { - temperature = biome.value().getModifiedClimateSettings().temperature(); - downfall = biome.value().getModifiedClimateSettings().downfall(); - } - - for (int localZ = 0; localZ < 16; localZ++) { - for (int localX = 0; localX < 16; localX++) { - int surfaceY = chunk.getHeight( - Heightmap.Types.WORLD_SURFACE_WG, localX, localZ) - 1; - int blockX = (chunkX << 4) + localX; - int blockZ = (chunkZ << 4) + localZ; - if (chunk.getBlockState(block.set(blockX, surfaceY, blockZ)) - .is(Blocks.PINK_CONCRETE)) { - pinkSurface++; - } - for (int depth = 1; depth <= 3; depth++) { - if (chunk.getBlockState(block.set(blockX, surfaceY - depth, blockZ)) - .is(Blocks.WHITE_CONCRETE)) { - whiteFiller++; - } - } - } - } - } - } - - int expectedChunks = (MAXIMUM_CHUNK - MINIMUM_CHUNK + 1) - * (MAXIMUM_CHUNK - MINIMUM_CHUNK + 1); - if (matchingChunks != expectedChunks || pinkSurface == 0L || whiteFiller == 0L) { - throw new IllegalStateException("Incomplete custom-biome generation: chunks=" - + matchingChunks + ", pink=" + pinkSurface + ", white=" + whiteFiller); - } - if (Float.compare(temperature, 1.35F) != 0 || Float.compare(downfall, 0.15F) != 0) { - throw new IllegalStateException("Custom biome climate was not loaded: temperature=" - + temperature + ", downfall=" + downfall); - } - return new AuditResult(matchingChunks, pinkSurface, whiteFiller, temperature, downfall); - } - - private static Properties readMarker(Path marker) { - if (!Files.isRegularFile(marker)) { - throw new IllegalStateException("Reload phase did not reuse the fresh test world: " + marker); - } - Properties values = new Properties(); - try (InputStream input = Files.newInputStream(marker)) { - values.load(input); - return values; - } catch (IOException exception) { - throw new IllegalStateException("Could not read biome integration marker", exception); - } - } - - private static void writeMarker(Path marker, long seed, AuditResult result) { - Properties values = new Properties(); - values.setProperty("seed", Long.toString(seed)); - values.setProperty("matching_chunks", Integer.toString(result.matchingChunks())); - values.setProperty("pink_surface", Long.toString(result.pinkSurface())); - values.setProperty("white_filler", Long.toString(result.whiteFiller())); - writeMarker(marker, values); - } - - private static void writeMarker(Path marker, Properties values) { - try (OutputStream output = Files.newOutputStream(marker)) { - values.store(output, "OreSpawn custom-biome integration test"); - } catch (IOException exception) { - throw new IllegalStateException("Could not write biome integration marker", exception); - } - } - - private static void assertReloadValue(Properties previous, String name, long actual) { - long expected; - try { - expected = Long.parseLong(previous.getProperty(name, "")); - } catch (NumberFormatException exception) { - throw new IllegalStateException("Invalid biome integration marker value: " + name, exception); - } - if (expected != actual) { - throw new IllegalStateException("Reloaded biome integration value changed for " + name - + ": expected " + expected + " but found " + actual); - } - } - - private record AuditResult(int matchingChunks, long pinkSurface, long whiteFiller, - float temperature, float downfall) { - } -} diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java new file mode 100644 index 00000000..504186a6 --- /dev/null +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -0,0 +1,646 @@ +package zone.moddev.mc.orespawn.testmod; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import zone.moddev.mc.orespawn.api.BiomePlacementMode; +import zone.moddev.mc.orespawn.api.BiomeRegionSize; +import zone.moddev.mc.orespawn.api.BiomeReplacementScope; +import zone.moddev.mc.orespawn.api.GeologyFamily; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.orespawn.api.ProviderStatus; +import zone.moddev.mc.orespawn.api.WorldgenProvider; +import zone.moddev.mc.orespawn.api.WorldgenProvider.BiomeSurfaceDefinition; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; + +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.DyeColor; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.FlatLevelSource; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.FeaturePlaceContext; +import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration; +import net.minecraft.world.level.storage.LevelResource; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.server.ServerAboutToStartEvent; +import net.neoforged.neoforge.event.server.ServerStartedEvent; +import net.neoforged.fml.common.Mod; +import net.neoforged.fml.event.lifecycle.InterModEnqueueEvent; +import net.neoforged.neoforge.registries.DeferredRegister; +import net.minecraft.core.registries.BuiltInRegistries; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** Independent, test-only provider surface fixture. */ +@Mod(SurfaceProbeTestMod.MODID) +public final class SurfaceProbeTestMod { + static final String MODID = "surfaceprobe"; + + private static final Logger LOGGER = LogManager.getLogger(); + private static final DeferredRegister> FEATURES = + DeferredRegister.create(BuiltInRegistries.FEATURE, MODID); + private static final ResourceKey OPEN = Level.END; + private static final ResourceKey ROOFED = Level.NETHER; + private static final Identifier OPEN_ID = Identifier.parse("minecraft:the_end"); + private static final Identifier ROOFED_ID = Identifier.parse("minecraft:the_nether"); + private static final Identifier BIOME_A = Identifier.parse(MODID + ":surface_a"); + private static final Identifier BIOME_B = Identifier.parse(MODID + ":surface_b"); + private static final Identifier PROBE_GEOME = Identifier.parse(MODID + ":dynamic_biome_geome"); + private static final Identifier DYNAMIC_FLUID = Identifier.parse(MODID + ":fluid/dynamic_water"); + private static final Identifier[] BUILT_IN_GEOMES = { + Identifier.parse("orespawn:stable_craton"), Identifier.parse("orespawn:mountain_belt"), + Identifier.parse("orespawn:volcanic_arc"), Identifier.parse("orespawn:sedimentary_basin"), + Identifier.parse("orespawn:coastal_shelf"), Identifier.parse("orespawn:arid_basin"), + Identifier.parse("orespawn:wetland_basin"), Identifier.parse("orespawn:glacial_highland") + }; + private static final int MINIMUM_CHUNK = 63; + private static final int MAXIMUM_CHUNK = 65; + private static final int EXPECTED_COLUMNS = 9 * 16 * 16; + private static final int EXPECTED_FILLER = EXPECTED_COLUMNS * 3; + private static final String PHASE_PROPERTY = "surfaceprobe.integrationPhase"; + private static final String MARKER_NAME = "surfaceprobe-integration.properties"; + private static final String CHEST_ITEM_NAME = "surfaceprobe sentinel"; + + static { + FEATURES.register("terrain_setup", () -> new ProbeFeature(ProbeStage.TERRAIN)); + FEATURES.register("structure_sentinels", () -> new ProbeFeature(ProbeStage.STRUCTURE)); + FEATURES.register("vegetation_sentinels", () -> new ProbeFeature(ProbeStage.VEGETATION)); + } + + public SurfaceProbeTestMod(IEventBus modBus) { + FEATURES.register(modBus); + modBus.addListener(this::enqueueProvider); + NeoForge.EVENT_BUS.addListener(this::enableGeologyProbe); + NeoForge.EVENT_BUS.addListener(this::auditGeneratedSurfaces); + } + + private void enqueueProvider(InterModEnqueueEvent event) { + WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); + addDynamicBiomeGeology(provider); + provider.fluidDeposit(DYNAMIC_FLUID, blockId(Blocks.WATER), deposit -> deposit + .dimension(OPEN_ID, placement -> placement + .yRange(16, 24) + .attempts(12.0D) + .radius(1, 1) + .verticalRadius(1, 1) + .maxLobes(1) + .minSolidCover(1) + .minSolidShell(1) + .hostBlock(blockId(Blocks.CALCITE)))); + addPalette(provider, "open_palette", OPEN_ID, false); + addPalette(provider, "roofed_palette", ROOFED_ID, true); + provider.dimensionMaterials(Identifier.parse(MODID + ":materials/nether"), ROOFED_ID, + materials -> materials.defaultFluid(blockId(Blocks.WATER))); + if (!OreSpawnApi.enqueue(provider.build())) { + throw new IllegalStateException("Could not enqueue surface probe provider"); + } + } + + private static void addDynamicBiomeGeology(WorldgenProvider.Builder provider) { + provider.geome(PROBE_GEOME, geome -> geome + .baseWeight(0.0D) + .familyWeight(GeologyFamily.SEDIMENTARY, 1.0D)); + provider.rock(Identifier.parse(MODID + ":rock/dynamic_biome"), blockId(Blocks.CALCITE), + GeologyFamily.SEDIMENTARY, rock -> { + rock.dimensions(java.util.Collections.singleton(OPEN_ID)); + rock.geomeWeight(PROBE_GEOME, 1.0D); + for (Identifier geome : BUILT_IN_GEOMES) rock.geomeWeight(geome, 0.0D); + }); + provider.rock(Identifier.parse(MODID + ":rock/fallback"), blockId(Blocks.BASALT), + GeologyFamily.SEDIMENTARY, rock -> { + rock.dimensions(java.util.Collections.singleton(OPEN_ID)); + rock.geomeWeight(PROBE_GEOME, 0.0D); + for (Identifier geome : BUILT_IN_GEOMES) rock.geomeWeight(geome, 1.0D); + }); + provider.biome(BIOME_A, java.util.Collections.singletonMap(PROBE_GEOME, 100.0D)); + provider.biome(BIOME_B, java.util.Collections.singletonMap(PROBE_GEOME, 100.0D)); + } + + private void enableGeologyProbe(ServerAboutToStartEvent event) { + Path profile = event.getServer().getWorldPath(LevelResource.ROOT).resolve("serverconfig") + .resolve("orespawn-worldgen.json"); + JsonObject root; + try (var reader = Files.newBufferedReader(profile)) { + root = new JsonParser().parse(reader).getAsJsonObject(); + } catch (IOException | RuntimeException exception) { + throw new IllegalStateException("Could not read the test-owned End geology profile", exception); + } + try { + root.addProperty("place_fluid_deposits", true); + JsonObject terrain = root.getAsJsonObject("terrain_dimensions"); + if (terrain == null) { + terrain = new JsonObject(); + root.add("terrain_dimensions", terrain); + } + JsonObject end = new JsonObject(); + end.addProperty("enabled", true); + end.add("biome_ids", new JsonArray()); + JsonArray namespaces = new JsonArray(); + namespaces.add(MODID); + end.add("biome_namespaces", namespaces); + JsonArray hosts = new JsonArray(); + hosts.add(blockId(Blocks.END_STONE).toString()); + end.add("host_blocks", hosts); + end.add("host_tags", new JsonArray()); + terrain.add(OPEN_ID.toString(), end); + try (var writer = Files.newBufferedWriter(profile)) { + new GsonBuilder().setPrettyPrinting().create().toJson(root, writer); + } + } catch (IOException | RuntimeException exception) { + throw new IllegalStateException("Could not write the test-owned End geology profile", exception); + } + if (!WorldGeologyProfileManager.reloadActiveProfile()) { + throw new IllegalStateException("Could not reload the test-owned End geology profile"); + } + } + + private static void addPalette(WorldgenProvider.Builder provider, String name, + Identifier dimension, boolean ceiling) { + BiomeSurfaceDefinition surfaceA = surface(DyeColor.PINK, DyeColor.WHITE, + DyeColor.BLUE, ceiling ? DyeColor.ORANGE : null); + BiomeSurfaceDefinition surfaceB = surface(DyeColor.LIME, DyeColor.YELLOW, + DyeColor.LIGHT_BLUE, ceiling ? DyeColor.MAGENTA : null); + provider.biomePalette(Identifier.parse(MODID + ":" + name), dimension, + palette -> palette + .mode(BiomePlacementMode.REPLACE) + .scope(BiomeReplacementScope.MINECRAFT_ONLY) + .regionSize(BiomeRegionSize.TINY) + .coverage(1.0D) + .fallbackWeight(0.0D) + .biome(BIOME_A, biome -> biome + .weight(1.0D) + .temperature(-2.0D, 2.0D) + .downfall(0.0D, 1.0D) + .surface(surfaceA)) + .biome(BIOME_B, biome -> biome + .weight(1.0D) + .temperature(-2.0D, 2.0D) + .downfall(0.0D, 1.0D) + .surface(surfaceB))); + } + + private static BiomeSurfaceDefinition surface(DyeColor top, DyeColor filler, + DyeColor underwater, DyeColor ceiling) { + BiomeSurfaceDefinition.Builder builder = BiomeSurfaceDefinition.builder() + .topBlock(blockId(concreteBlock(top))) + .fillerBlock(blockId(concreteBlock(filler))) + .fillerDepth(3) + .underwaterBlock(blockId(concreteBlock(underwater))); + if (ceiling != null) { + builder.ceilingBlock(blockId(concreteBlock(ceiling))); + } + return builder.build(); + } + + private static Identifier blockId(Block block) { + return BuiltInRegistries.BLOCK.getKey(block); + } + + private void auditGeneratedSurfaces(ServerStartedEvent event) { + String phase = System.getProperty(PHASE_PROPERTY, "").trim(); + if (!phase.equals("fresh") && !phase.equals("reload")) { + throw new IllegalStateException("Missing or invalid " + PHASE_PROPERTY + ": " + phase); + } + if (OreSpawnApi.getProviderStatus(MODID) != ProviderStatus.ACTIVE) { + throw new IllegalStateException("Surface probe provider is not active"); + } + + Path marker = event.getServer().getWorldPath(LevelResource.ROOT).resolve(MARKER_NAME); + Properties previous = phase.equals("reload") ? readMarker(marker) : null; + if (phase.equals("fresh") && Files.exists(marker)) { + throw new IllegalStateException("Fresh surface probe retained a reload marker"); + } + + Map results = new LinkedHashMap<>(); + results.put("open", auditDimension(requireLevel(event, OPEN), false)); + results.put("roofed", auditDimension(requireLevel(event, ROOFED), true)); + Properties current = properties(event.getServer().overworld().getSeed(), results); + if (previous == null) { + writeMarker(marker, current); + } else { + for (String name : current.stringPropertyNames()) { + String expected = previous.getProperty(name); + String actual = current.getProperty(name); + if (!actual.equals(expected)) { + throw new IllegalStateException("Reloaded surface value changed for " + name + + ": expected " + expected + " but found " + actual); + } + } + previous.setProperty("reload_verified", "true"); + writeMarker(marker, previous); + } + + LOGGER.info("SURFACEPROBE PASS phase={} open={} roofed={}", + phase, results.get("open"), results.get("roofed")); + } + + private static ServerLevel requireLevel(ServerStartedEvent event, ResourceKey key) { + ServerLevel level = event.getServer().getLevel(key); + if (level == null) throw new IllegalStateException("Surface probe dimension is unavailable: " + key.identifier()); + if (level.getChunkSource().getGenerator() instanceof FlatLevelSource) { + throw new IllegalStateException("Surface probe requires normal noise terrain: " + key.identifier()); + } + return level; + } + + private static AuditResult auditDimension(ServerLevel level, boolean roofed) { + long top = 0L; + long underwater = 0L; + long filler = 0L; + long geology = 0L; + long ceiling = 0L; + long roofTop = 0L; + int biomeA = 0; + int biomeB = 0; + int edgeChanges = 0; + int sentinels = 0; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + + for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { + Identifier previousChunkBiome = null; + for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { + level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, true); + LevelChunk chunk = level.getChunk(chunkX, chunkZ); + int chunkMinX = chunkX << 4; + int chunkMinZ = chunkZ << 4; + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + int x = chunkMinX + localX; + int z = chunkMinZ + localZ; + int groundY = findMarkedGround(chunk, pos, x, z, level.getMinY(), level.getMaxY()); + var biome = level.getBiome(pos.set(x, groundY, z)); + Identifier biomeId = biomeId(biome); + Material material = material(biomeId, roofed); + float expectedTemperature = BIOME_A.equals(biomeId) ? 1.35F : 0.7F; + float expectedDownfall = BIOME_A.equals(biomeId) ? 0.15F : 0.8F; + var climate = biome.value().getModifiedClimateSettings(); + if (Float.compare(climate.temperature(), expectedTemperature) != 0 + || Float.compare(climate.downfall(), expectedDownfall) != 0) { + throw new IllegalStateException("Provider climate changed for " + biomeId + + " at " + pos + ": " + climate); + } + if (BIOME_A.equals(biomeId)) biomeA++; else biomeB++; + boolean waterColumn = localX == 1 && localZ == 1; + BlockState expectedTop = waterColumn ? material.underwater() : material.top(); + assertBlock(chunk, pos, x, groundY, z, expectedTop, + "provider top at exposed marked ground"); + if (waterColumn) underwater++; else top++; + for (int depth = 1; depth <= 3; depth++) { + assertBlock(chunk, pos, x, groundY - depth, z, material.filler(), + "provider filler depth " + depth); + filler++; + } + if (!roofed) { + for (int depth = 6; depth <= 8; depth++) { + assertBlock(chunk, pos, x, groundY - depth, z, + Blocks.CALCITE.defaultBlockState(), "dynamic-biome geome rock"); + geology++; + } + } + if (roofed) { + Identifier ceilingBiome = biomeId(level.getBiome( + pos.set(x, groundY + 8, z))); + BlockState expectedCeiling = material(ceilingBiome, true).ceiling(); + assertBlock(chunk, pos, x, groundY + 8, z, expectedCeiling, + "roof underside"); + assertBlock(chunk, pos, x, groundY + 10, z, Blocks.STONE.defaultBlockState(), + "roof top"); + ceiling++; + roofTop++; + } + } + } + Identifier centerBiome = biomeId(level.getBiome(pos.set(chunkMinX + 8, + findMarkedGround(chunk, pos, chunkMinX + 8, chunkMinZ + 8, + level.getMinY(), level.getMaxY()), chunkMinZ + 8))); + if (previousChunkBiome != null && !previousChunkBiome.equals(centerBiome)) edgeChanges++; + previousChunkBiome = centerBiome; + sentinels += auditSentinels(level, chunk, pos, chunkMinX, chunkMinZ); + } + } + + if (top != EXPECTED_COLUMNS - 9 || underwater != 9 || filler != EXPECTED_FILLER + || biomeA == 0 || biomeB == 0 || edgeChanges == 0 || sentinels != 9 * 4 + || geology != (roofed ? 0 : EXPECTED_FILLER) + || (roofed && (ceiling != EXPECTED_COLUMNS || roofTop != EXPECTED_COLUMNS))) { + throw new IllegalStateException("Incomplete surface audit for " + level.dimension().identifier() + + ": top=" + top + ", underwater=" + underwater + ", filler=" + filler + + ", biomeA=" + biomeA + ", biomeB=" + biomeB + ", edges=" + edgeChanges + + ", sentinels=" + sentinels + ", geology=" + geology + + ", ceiling=" + ceiling + ", roofTop=" + roofTop); + } + long aquiferFluid = roofed ? 0L : auditDynamicFluid(level); + return new AuditResult(top, underwater, filler, geology, ceiling, roofTop, + biomeA, biomeB, edgeChanges, sentinels, aquiferFluid); + } + + private static long auditDynamicFluid(ServerLevel level) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + long water = 0L; + for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { + for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { + level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, true); + LevelChunk chunk = level.getChunk(chunkX, chunkZ); + for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++) { + for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) { + for (int y = 12; y <= 30; y++) { + if (chunk.getBlockState(pos.set(x, y, z)).is(Blocks.WATER)) water++; + } + } + } + } + } + if (water == 0L) { + throw new IllegalStateException("NeoForge 26.1.2 dynamic fluid deposit produced no covered flowing-water blocks"); + } + return water; + } + + private static int auditSentinels(ServerLevel level, LevelChunk chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ) { + int groundTree = findMarkedGround(chunk, pos, minX + 4, minZ + 4, + level.getMinY(), level.getMaxY()); + assertBlock(chunk, pos, minX + 4, groundTree + 1, minZ + 4, + Blocks.OAK_LOG.defaultBlockState(), "tree log"); + assertBlock(chunk, pos, minX + 4, groundTree + 3, minZ + 4, + Blocks.OAK_LEAVES.defaultBlockState(), "tree leaves"); + + int groundVegetation = findMarkedGround(chunk, pos, minX + 6, minZ + 6, + level.getMinY(), level.getMaxY()); + assertBlock(chunk, pos, minX + 6, groundVegetation + 1, minZ + 6, + Blocks.DIRT.defaultBlockState(), "vegetation substrate"); + assertBlock(chunk, pos, minX + 6, groundVegetation + 2, minZ + 6, + Blocks.OAK_SAPLING.defaultBlockState(), "vegetation"); + + int groundStructure = findMarkedGround(chunk, pos, minX + 8, minZ + 8, + level.getMinY(), level.getMaxY()); + assertBlock(chunk, pos, minX + 8, groundStructure + 1, minZ + 8, + Blocks.GOLD_BLOCK.defaultBlockState(), "authored structure"); + + int groundChest = findMarkedGround(chunk, pos, minX + 10, minZ + 10, + level.getMinY(), level.getMaxY()); + assertBlock(chunk, pos, minX + 10, groundChest + 1, minZ + 10, + Blocks.CHEST.defaultBlockState(), "chest sentinel"); + if (!(level.getBlockEntity(pos.set(minX + 10, groundChest + 1, minZ + 10)) + instanceof ChestBlockEntity chest) + || !chest.getItem(0).is(Items.DIAMOND) + || chest.getItem(0).getHoverName() == null + || !CHEST_ITEM_NAME.equals(chest.getItem(0).getHoverName().getString())) { + throw new IllegalStateException("Chest block entity data changed at " + pos); + } + return 4; + } + + private static int findMarkedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, + int x, int z, int minY, int maxY) { + for (int y = maxY - 1; y >= minY; y--) { + if (chunk.getBlockState(pos.set(x, y, z)).is(concreteBlock(DyeColor.BLACK))) return y + 5; + } + throw new IllegalStateException("Independent surface marker missing at " + x + "," + z); + } + + private static void assertBlock(ChunkAccess chunk, BlockPos.MutableBlockPos pos, + int x, int y, int z, BlockState expected, String purpose) { + BlockState actual = chunk.getBlockState(pos.set(x, y, z)); + if (!actual.is(expected.getBlock())) { + throw new IllegalStateException("Expected " + purpose + " " + expected.getBlock() + + " at " + pos + " but found " + actual.getBlock()); + } + } + + private static Material material(Identifier biome, boolean roofed) { + if (BIOME_A.equals(biome)) { + return new Material(concrete(DyeColor.PINK), concrete(DyeColor.WHITE), + concrete(DyeColor.BLUE), roofed ? concrete(DyeColor.ORANGE) : null); + } + if (BIOME_B.equals(biome)) { + return new Material(concrete(DyeColor.LIME), concrete(DyeColor.YELLOW), + concrete(DyeColor.LIGHT_BLUE), roofed ? concrete(DyeColor.MAGENTA) : null); + } + throw new IllegalStateException("Unexpected provider biome " + biome); + } + + private static BlockState concrete(DyeColor color) { + return concreteBlock(color).defaultBlockState(); + } + + private static Block concreteBlock(DyeColor color) { + return switch (color) { + case WHITE -> Blocks.WHITE_CONCRETE; + case ORANGE -> Blocks.ORANGE_CONCRETE; + case MAGENTA -> Blocks.MAGENTA_CONCRETE; + case LIGHT_BLUE -> Blocks.LIGHT_BLUE_CONCRETE; + case YELLOW -> Blocks.YELLOW_CONCRETE; + case LIME -> Blocks.LIME_CONCRETE; + case PINK -> Blocks.PINK_CONCRETE; + case GRAY -> Blocks.GRAY_CONCRETE; + case LIGHT_GRAY -> Blocks.LIGHT_GRAY_CONCRETE; + case CYAN -> Blocks.CYAN_CONCRETE; + case PURPLE -> Blocks.PURPLE_CONCRETE; + case BLUE -> Blocks.BLUE_CONCRETE; + case BROWN -> Blocks.BROWN_CONCRETE; + case GREEN -> Blocks.GREEN_CONCRETE; + case RED -> Blocks.RED_CONCRETE; + case BLACK -> Blocks.BLACK_CONCRETE; + }; + } + + private static Identifier biomeId(net.minecraft.core.Holder biome) { + return biome.unwrapKey().map(key -> key.identifier()).orElse(null); + } + + private static Properties properties(long seed, Map results) { + Properties values = new Properties(); + values.setProperty("seed", Long.toString(seed)); + values.setProperty("dimensions", Integer.toString(results.size())); + values.setProperty("columns_per_dimension", Integer.toString(EXPECTED_COLUMNS)); + for (Map.Entry entry : results.entrySet()) { + String prefix = entry.getKey() + "."; + AuditResult result = entry.getValue(); + values.setProperty(prefix + "top", Long.toString(result.top())); + values.setProperty(prefix + "underwater", Long.toString(result.underwater())); + values.setProperty(prefix + "filler", Long.toString(result.filler())); + values.setProperty(prefix + "geology", Long.toString(result.geology())); + values.setProperty(prefix + "ceiling", Long.toString(result.ceiling())); + values.setProperty(prefix + "roof_top", Long.toString(result.roofTop())); + values.setProperty(prefix + "biome_a", Integer.toString(result.biomeA())); + values.setProperty(prefix + "biome_b", Integer.toString(result.biomeB())); + values.setProperty(prefix + "edge_changes", Integer.toString(result.edgeChanges())); + values.setProperty(prefix + "sentinels", Integer.toString(result.sentinels())); + values.setProperty(prefix + "aquifer_fluid", Long.toString(result.aquiferFluid())); + } + return values; + } + + private static Properties readMarker(Path marker) { + if (!Files.isRegularFile(marker)) { + throw new IllegalStateException("Reload phase did not reuse the fresh test world: " + marker); + } + Properties values = new Properties(); + try (InputStream input = Files.newInputStream(marker)) { + values.load(input); + return values; + } catch (IOException exception) { + throw new IllegalStateException("Could not read surface integration marker", exception); + } + } + + private static void writeMarker(Path marker, Properties values) { + try (OutputStream output = Files.newOutputStream(marker)) { + values.store(output, "OreSpawn provider surface integration test"); + } catch (IOException exception) { + throw new IllegalStateException("Could not write surface integration marker", exception); + } + } + + private enum ProbeStage { TERRAIN, STRUCTURE, VEGETATION } + + private static final class ProbeFeature extends Feature { + private final ProbeStage stage; + + private ProbeFeature(ProbeStage stage) { + super(NoneFeatureConfiguration.CODEC); + this.stage = stage; + } + + @Override + public boolean place(FeaturePlaceContext context) { + WorldGenLevel world = context.level(); + ChunkAccess chunk = world.getChunk(context.origin()); + return switch (stage) { + case TERRAIN -> prepareTerrain(world, chunk); + case STRUCTURE -> placeStructureSentinels(world, chunk); + case VEGETATION -> placeVegetationSentinels(world, chunk); + }; + } + } + + private static boolean prepareTerrain(WorldGenLevel world, ChunkAccess chunk) { + boolean roofed = world.getLevel().dimension().equals(ROOFED); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + Heightmap surfaceHeight = chunk.getOrCreateHeightmapUnprimed( + Heightmap.Types.WORLD_SURFACE_WG); + int minX = chunk.getPos().getMinBlockX(); + int minZ = chunk.getPos().getMinBlockZ(); + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int x = minX + localX; + int z = minZ + localZ; + int groundY = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, localX, localZ); + if (roofed) { + while (groundY > world.getMinY() + && solid(chunk.getBlockState(pos.set(x, groundY, z)))) groundY--; + } + while (groundY > world.getMinY() + && !solid(chunk.getBlockState(pos.set(x, groundY, z)))) groundY--; + if (!roofed && groundY <= world.getMinY()) groundY = 64; + chunk.setBlockState(pos.set(x, groundY, z), Blocks.GRASS_BLOCK.defaultBlockState(), 0); + surfaceHeight.update(localX, groundY, localZ, Blocks.GRASS_BLOCK.defaultBlockState()); + for (int depth = 1; depth <= 3; depth++) { + chunk.setBlockState(pos.set(x, groundY - depth, z), Blocks.DIRT.defaultBlockState(), 0); + } + if (!roofed) { + for (int depth = 6; depth <= 60 && groundY - depth >= 1; depth++) { + chunk.setBlockState(pos.set(x, groundY - depth, z), Blocks.END_STONE.defaultBlockState(), 0); + } + } + chunk.setBlockState(pos.set(x, groundY - 5, z), + concreteBlock(DyeColor.BLACK).defaultBlockState(), 0); + if (roofed) { + for (int openY = groundY + 1; openY < world.getMaxY(); openY++) { + chunk.setBlockState(pos.set(x, openY, z), Blocks.AIR.defaultBlockState(), 0); + } + for (int roofY = groundY + 8; roofY <= groundY + 10; roofY++) { + chunk.setBlockState(pos.set(x, roofY, z), Blocks.STONE.defaultBlockState(), 0); + } + surfaceHeight.update(localX, groundY + 10, localZ, + Blocks.STONE.defaultBlockState()); + } + if (localX == 1 && localZ == 1) { + chunk.setBlockState(pos.set(x, groundY + 1, z), Blocks.WATER.defaultBlockState(), 0); + surfaceHeight.update(localX, groundY + 1, localZ, Blocks.WATER.defaultBlockState()); + } + } + } + return true; + } + + private static boolean solid(BlockState state) { + return !state.isAir() && state.getFluidState().isEmpty(); + } + + private static boolean placeStructureSentinels(WorldGenLevel world, ChunkAccess chunk) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int minX = chunk.getPos().getMinBlockX(); + int minZ = chunk.getPos().getMinBlockZ(); + int structureY = markedGround(chunk, pos, minX + 8, minZ + 8, world); + world.setBlock(pos.set(minX + 8, structureY + 1, minZ + 8), + Blocks.GOLD_BLOCK.defaultBlockState(), 2); + int chestY = markedGround(chunk, pos, minX + 10, minZ + 10, world); + world.setBlock(pos.set(minX + 10, chestY + 1, minZ + 10), Blocks.CHEST.defaultBlockState(), 2); + if (world.getBlockEntity(pos) instanceof ChestBlockEntity chest) { + ItemStack sentinel = new ItemStack(Items.DIAMOND); + sentinel.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, + Component.literal(CHEST_ITEM_NAME)); + chest.setItem(0, sentinel); + chest.setChanged(); + } + return true; + } + + private static boolean placeVegetationSentinels(WorldGenLevel world, ChunkAccess chunk) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int minX = chunk.getPos().getMinBlockX(); + int minZ = chunk.getPos().getMinBlockZ(); + int treeY = markedGround(chunk, pos, minX + 4, minZ + 4, world); + for (int y = 1; y <= 2; y++) { + world.setBlock(pos.set(minX + 4, treeY + y, minZ + 4), Blocks.OAK_LOG.defaultBlockState(), 2); + } + world.setBlock(pos.set(minX + 4, treeY + 3, minZ + 4), Blocks.OAK_LEAVES.defaultBlockState(), 2); + int vegetationY = markedGround(chunk, pos, minX + 6, minZ + 6, world); + world.setBlock(pos.set(minX + 6, vegetationY + 1, minZ + 6), Blocks.DIRT.defaultBlockState(), 2); + world.setBlock(pos.set(minX + 6, vegetationY + 2, minZ + 6), Blocks.OAK_SAPLING.defaultBlockState(), 2); + return true; + } + + private static int markedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, + int x, int z, WorldGenLevel world) { + return findMarkedGround(chunk, pos, x, z, world.getMinY(), world.getMaxY()); + } + + private record Material(BlockState top, BlockState filler, + BlockState underwater, BlockState ceiling) { } + + private record AuditResult(long top, long underwater, long filler, long geology, + long ceiling, long roofTop, int biomeA, int biomeB, + int edgeChanges, int sentinels, long aquiferFluid) { } +} diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/mixin/GameTestMainUtilMixin.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/mixin/GameTestMainUtilMixin.java index e5817f2b..b58d1c86 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/mixin/GameTestMainUtilMixin.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/mixin/GameTestMainUtilMixin.java @@ -14,13 +14,13 @@ @Mixin(GameTestMainUtil.class) abstract class GameTestMainUtilMixin { @Inject(method = "createOrResetDir", at = @At("HEAD"), cancellable = true, remap = false) - private static void cakeworldprobe$preserveReloadUniverse(String universePath, + private static void surfaceprobe$preserveReloadUniverse(String universePath, CallbackInfo callback) { - if (!"reload".equals(System.getProperty("cakeworld.biomeIntegrationPhase"))) return; + if (!"reload".equals(System.getProperty("surfaceprobe.integrationPhase"))) return; Path universe = Path.of(universePath); if (!Files.isDirectory(universe)) { throw new IllegalStateException( - "Biome integration reload universe is missing: " + universe); + "Surface integration reload universe is missing: " + universe); } callback.cancel(); } diff --git a/src/biomeIntegrationTest/resources/META-INF/neoforge.mods.toml b/src/biomeIntegrationTest/resources/META-INF/neoforge.mods.toml index a07a918a..f64ecbee 100644 --- a/src/biomeIntegrationTest/resources/META-INF/neoforge.mods.toml +++ b/src/biomeIntegrationTest/resources/META-INF/neoforge.mods.toml @@ -1,22 +1,22 @@ license="LGPL-2.1" [[mods]] -modId="cakeworldprobe" +modId="surfaceprobe" version="1" -displayName="CakeWorld Biome Integration Test" -description='''Test-only provider mod for OreSpawn's custom-biome integration gate.''' +displayName="OreSpawn Surface Integration Test" +description='''Test-only provider mod for OreSpawn's surface replacement gate.''' [[mixins]] -config="cakeworldprobe.mixins.json" +config="surfaceprobe.mixins.json" -[[dependencies.cakeworldprobe]] +[[dependencies.surfaceprobe]] modId="orespawn" type="required" -versionRange="[4.0.5,5.0.0)" +versionRange="[4.0.6,5.0.0)" ordering="AFTER" side="BOTH" -[[dependencies.cakeworldprobe]] +[[dependencies.surfaceprobe]] modId="minecraft" type="required" versionRange="[26.1.2]" diff --git a/src/biomeIntegrationTest/resources/data/cakeworldprobe/worldgen/biome/cake_plains.json b/src/biomeIntegrationTest/resources/data/cakeworldprobe/worldgen/biome/cake_plains.json deleted file mode 100644 index 6d2563c2..00000000 --- a/src/biomeIntegrationTest/resources/data/cakeworldprobe/worldgen/biome/cake_plains.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "attributes": { - "minecraft:audio/ambient_sounds": { - "additions": { - "sound": "minecraft:ambient.nether_wastes.additions", - "tick_chance": 0.0111 - }, - "loop": "minecraft:ambient.nether_wastes.loop", - "mood": { - "block_search_extent": 8, - "offset": 2.0, - "sound": "minecraft:ambient.nether_wastes.mood", - "tick_delay": 6000 - } - }, - "minecraft:audio/background_music": { - "default": { - "max_delay": 24000, - "min_delay": 12000, - "sound": "minecraft:music.nether.nether_wastes" - } - }, - "minecraft:visual/fog_color": "#330808" - }, - "carvers": "minecraft:nether_cave", - "downfall": 0.15, - "effects": { - "water_color": "#3f76e4" - }, - "features": [ - [], - [], - [], - [], - [], - [], - [], - [ - "minecraft:spring_open", - "minecraft:patch_fire", - "minecraft:patch_soul_fire", - "minecraft:glowstone_extra", - "minecraft:glowstone", - "minecraft:brown_mushroom_nether", - "minecraft:red_mushroom_nether", - "minecraft:ore_magma", - "minecraft:spring_closed", - "minecraft:ore_gravel_nether", - "minecraft:ore_blackstone", - "minecraft:ore_gold_nether", - "minecraft:ore_quartz_nether", - "minecraft:ore_ancient_debris_large", - "minecraft:ore_debris_small" - ], - [], - [ - "minecraft:spring_lava", - "minecraft:brown_mushroom_normal", - "minecraft:red_mushroom_normal" - ] - ], - "has_precipitation": false, - "spawn_costs": {}, - "spawners": { - "ambient": [], - "axolotls": [], - "creature": [ - { - "type": "minecraft:strider", - "maxCount": 2, - "minCount": 1, - "weight": 60 - } - ], - "misc": [], - "monster": [ - { - "type": "minecraft:ghast", - "maxCount": 4, - "minCount": 4, - "weight": 50 - }, - { - "type": "minecraft:zombified_piglin", - "maxCount": 4, - "minCount": 4, - "weight": 100 - }, - { - "type": "minecraft:magma_cube", - "maxCount": 4, - "minCount": 4, - "weight": 2 - }, - { - "type": "minecraft:enderman", - "maxCount": 4, - "minCount": 4, - "weight": 1 - }, - { - "type": "minecraft:piglin", - "maxCount": 4, - "minCount": 4, - "weight": 15 - } - ], - "underground_water_creature": [], - "water_ambient": [], - "water_creature": [] - }, - "temperature": 1.35 -} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/neoforge/biome_modifier/structure_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/neoforge/biome_modifier/structure_sentinels.json new file mode 100644 index 00000000..5eb9eb88 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/neoforge/biome_modifier/structure_sentinels.json @@ -0,0 +1,15 @@ +{ + "type": "neoforge:add_features", + "biomes": [ + "minecraft:the_end", + "minecraft:nether_wastes", + "minecraft:soul_sand_valley", + "minecraft:crimson_forest", + "minecraft:warped_forest", + "minecraft:basalt_deltas" + ], + "features": [ + "surfaceprobe:structure_sentinels" + ], + "step": "surface_structures" +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/neoforge/biome_modifier/terrain_setup.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/neoforge/biome_modifier/terrain_setup.json new file mode 100644 index 00000000..238f9202 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/neoforge/biome_modifier/terrain_setup.json @@ -0,0 +1,15 @@ +{ + "type": "neoforge:add_features", + "biomes": [ + "minecraft:the_end", + "minecraft:nether_wastes", + "minecraft:soul_sand_valley", + "minecraft:crimson_forest", + "minecraft:warped_forest", + "minecraft:basalt_deltas" + ], + "features": [ + "surfaceprobe:terrain_setup" + ], + "step": "raw_generation" +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/neoforge/biome_modifier/vegetation_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/neoforge/biome_modifier/vegetation_sentinels.json new file mode 100644 index 00000000..c4c4f4cd --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/neoforge/biome_modifier/vegetation_sentinels.json @@ -0,0 +1,15 @@ +{ + "type": "neoforge:add_features", + "biomes": [ + "minecraft:the_end", + "minecraft:nether_wastes", + "minecraft:soul_sand_valley", + "minecraft:crimson_forest", + "minecraft:warped_forest", + "minecraft:basalt_deltas" + ], + "features": [ + "surfaceprobe:vegetation_sentinels" + ], + "step": "vegetal_decoration" +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_a.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_a.json new file mode 100644 index 00000000..b2567de5 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_a.json @@ -0,0 +1,44 @@ +{ + "attributes": { + "minecraft:audio/ambient_sounds": { + "mood": { + "block_search_extent": 8, + "offset": 2.0, + "sound": "minecraft:ambient.cave", + "tick_delay": 6000 + } + }, + "minecraft:visual/fog_color": "#c0d8ff" + }, + "carvers": [], + "downfall": 0.15, + "effects": { + "water_color": "#3f76e4" + }, + "features": [ + ["surfaceprobe:terrain_setup"], + [], + [], + [], + ["surfaceprobe:structure_sentinels"], + [], + [], + [], + [], + ["surfaceprobe:vegetation_sentinels"], + [] + ], + "has_precipitation": true, + "spawn_costs": {}, + "spawners": { + "ambient": [], + "axolotls": [], + "creature": [], + "misc": [], + "monster": [], + "underground_water_creature": [], + "water_ambient": [], + "water_creature": [] + }, + "temperature": 1.35 +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_b.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_b.json new file mode 100644 index 00000000..fd8f56ca --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_b.json @@ -0,0 +1,44 @@ +{ + "attributes": { + "minecraft:audio/ambient_sounds": { + "mood": { + "block_search_extent": 8, + "offset": 2.0, + "sound": "minecraft:ambient.cave", + "tick_delay": 6000 + } + }, + "minecraft:visual/fog_color": "#c0d8ff" + }, + "carvers": [], + "downfall": 0.8, + "effects": { + "water_color": "#3f76e4" + }, + "features": [ + ["surfaceprobe:terrain_setup"], + [], + [], + [], + ["surfaceprobe:structure_sentinels"], + [], + [], + [], + [], + ["surfaceprobe:vegetation_sentinels"], + [] + ], + "has_precipitation": true, + "spawn_costs": {}, + "spawners": { + "ambient": [], + "axolotls": [], + "creature": [], + "misc": [], + "monster": [], + "underground_water_creature": [], + "water_ambient": [], + "water_creature": [] + }, + "temperature": 0.7 +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/structure_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/structure_sentinels.json new file mode 100644 index 00000000..52a252e3 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/structure_sentinels.json @@ -0,0 +1 @@ +{"type":"surfaceprobe:structure_sentinels","config":{}} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/terrain_setup.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/terrain_setup.json new file mode 100644 index 00000000..8dc1ac0b --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/terrain_setup.json @@ -0,0 +1 @@ +{"type":"surfaceprobe:terrain_setup","config":{}} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/vegetation_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/vegetation_sentinels.json new file mode 100644 index 00000000..676a1131 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/vegetation_sentinels.json @@ -0,0 +1 @@ +{"type":"surfaceprobe:vegetation_sentinels","config":{}} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/structure_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/structure_sentinels.json new file mode 100644 index 00000000..7c80ccb8 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/structure_sentinels.json @@ -0,0 +1 @@ +{"feature":"surfaceprobe:structure_sentinels","placement":[]} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/terrain_setup.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/terrain_setup.json new file mode 100644 index 00000000..63f998fe --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/terrain_setup.json @@ -0,0 +1 @@ +{"feature":"surfaceprobe:terrain_setup","placement":[]} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/vegetation_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/vegetation_sentinels.json new file mode 100644 index 00000000..01c8b35d --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/vegetation_sentinels.json @@ -0,0 +1 @@ +{"feature":"surfaceprobe:vegetation_sentinels","placement":[]} diff --git a/src/biomeIntegrationTest/resources/pack.mcmeta b/src/biomeIntegrationTest/resources/pack.mcmeta index d2c62daa..53e92b32 100644 --- a/src/biomeIntegrationTest/resources/pack.mcmeta +++ b/src/biomeIntegrationTest/resources/pack.mcmeta @@ -1,6 +1,6 @@ { "pack": { - "description": "OreSpawn custom-biome integration fixtures", + "description": "OreSpawn provider-surface integration fixtures", "max_format": 101, "min_format": [ 101, diff --git a/src/biomeIntegrationTest/resources/cakeworldprobe.mixins.json b/src/biomeIntegrationTest/resources/surfaceprobe.mixins.json similarity index 100% rename from src/biomeIntegrationTest/resources/cakeworldprobe.mixins.json rename to src/biomeIntegrationTest/resources/surfaceprobe.mixins.json diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java index 82f5be28..c82213d4 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java @@ -12,7 +12,6 @@ import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.biome.Biome; import net.minecraft.resources.Identifier; -import net.minecraft.core.registries.BuiltInRegistries; public final class BakedGeomeConfig { static final int MIN_Y = -64; @@ -48,7 +47,7 @@ public final class BakedGeomeConfig { BakedGeomeConfig(GeomeDefinition[] geomes, double geomeScale, double biomeInfluence, double regionalNoiseInfluence, double boundaryNoiseInfluence, Map biomeWeights, - RockEntry[] rocks, FormationSettings formations) { + Map biomeWeightsById, RockEntry[] rocks, FormationSettings formations) { this.geomes = geomes; this.geomeScale = geomeScale; this.biomeInfluence = biomeInfluence; @@ -57,9 +56,9 @@ public final class BakedGeomeConfig { this.formations = formations; this.familyDiversitySlots = formations.familyDiversitySlots(); this.biomeWeights = new IdentityHashMap<>(biomeWeights); - this.biomeWeightsById = new HashMap<>(); + this.biomeWeightsById = new HashMap<>(biomeWeightsById); for (Map.Entry entry : biomeWeights.entrySet()) { - Identifier biomeId = zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.id(entry.getKey()); + Identifier biomeId = BiomeRegistryAccess.id(entry.getKey()); if (biomeId != null) { biomeWeightsById.put(biomeId, entry.getValue()); } @@ -109,6 +108,23 @@ int pickGeome(Biome biome, Identifier biomeId, double[] regionalNoise, double bo return bestIndex; } + int scoreGeomes(Biome biome, Identifier biomeId, double[] regionalNoiseAndScores, double boundaryNoise) { + double[] weights = biomeWeightsFor(biome, biomeId); + double bestScore = Double.NEGATIVE_INFINITY; + int bestIndex = 0; + for (int i = 0; i < geomes.length; i++) { + double boundary = ((i & 1) == 0 ? boundaryNoise : -boundaryNoise) * boundaryNoiseInfluence; + double score = geomes[i].baseWeight + (weights[i] * biomeInfluence) + + (regionalNoiseAndScores[i] * regionalNoiseInfluence) + boundary; + regionalNoiseAndScores[i] = score; + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + return bestIndex; + } + public RockFamily pickFamily(int geomeIndex, int y, int formationValue) { return pickFamily(geomeIndex, y, formationValue, 0); } @@ -213,7 +229,7 @@ String describeBiomeWeights(Biome biome) { double[] weights = biomeWeights.get(biome); String source = "identity"; if (weights == null) { - Identifier biomeId = zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.id(biome); + Identifier biomeId = BiomeRegistryAccess.id(biome); weights = biomeId == null ? null : biomeWeightsById.get(biomeId); source = "registry-id"; } @@ -232,7 +248,7 @@ String describeBiomeWeights(Biome biome) { } String dominantBiomeWeight(Biome biome) { - double[] weights = biomeWeightsFor(biome, zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.id(biome)); + double[] weights = biomeWeightsFor(biome, BiomeRegistryAccess.id(biome)); int best = 0; for (int i = 1; i < weights.length; i++) { if (weights[i] > weights[best]) { @@ -243,7 +259,7 @@ String dominantBiomeWeight(Biome biome) { } boolean hasDistinctBiomeWeights(Biome biome) { - double[] weights = biomeWeightsFor(biome, zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.id(biome)); + double[] weights = biomeWeightsFor(biome, BiomeRegistryAccess.id(biome)); double first = weights[0]; for (int i = 1; i < weights.length; i++) { if (Math.abs(weights[i] - first) > 0.000001D) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java index 17cc09db..7d163195 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java @@ -13,7 +13,7 @@ import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration; import net.minecraft.world.level.levelgen.placement.PlacedFeature; -/** Applies explicit provider surface blocks after the source surface is built. */ +/** Applies provider surfaces after base surfaces and lakes, before late features. */ public final class BiomeSurfaceFeature extends Feature { public static final BiomeSurfaceFeature FEATURE = new BiomeSurfaceFeature(); private static Holder placedFeature; @@ -40,57 +40,99 @@ public boolean place(FeaturePlaceContext context) { boolean changed = false; int minX = chunk.getPos().getMinBlockX(); int minZ = chunk.getPos().getMinBlockZ(); + boolean ceilingDimension = world.getLevel().dimensionType().hasCeiling(); for (int localX = 0; localX < 16; localX++) { for (int localZ = 0; localZ < 16; localZ++) { int x = minX + localX; int z = minZ + localZ; - int y = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, localX, localZ) - 1; - while (y > world.getMinY()) { - cursor.set(x, y, z); - BlockState state = chunk.getBlockState(cursor); - if (!state.isAir() && state.getFluidState().isEmpty()) break; - y--; + int groundY; + int ceilingY = Integer.MIN_VALUE; + if (ceilingDimension) { + long column = findCeilingAndGround(chunk, cursor, x, z, + world.getMaxY(), world.getMinY()); + ceilingY = (int) (column >> 32); + groundY = (int) column; + } else { + groundY = findOpenGround(chunk, cursor, x, z, localX, localZ, + world.getMinY()); } - if (y <= world.getMinY()) continue; - cursor.set(x, y, z); - Surface surface = config.surfaces.get(world.getBiome(cursor)); - if (surface == null) continue; - boolean underwater = !chunk.getBlockState(cursor.above()).getFluidState().isEmpty(); - BlockState top = underwater && surface.underwater != null - ? surface.underwater : surface.top; - if (top != null && replaceable(chunk.getBlockState(cursor))) { - chunk.setBlockState(cursor, top, 0); - changed = true; + if (groundY >= world.getMinY()) { + changed |= applyGround(chunk, world, config, cursor, x, z, + groundY, world.getMinY()); } - if (surface.filler != null) { - for (int depth = 1; depth <= surface.fillerDepth - && y - depth >= world.getMinY(); depth++) { - cursor.set(x, y - depth, z); - if (!replaceable(chunk.getBlockState(cursor))) break; - chunk.setBlockState(cursor, surface.filler, 0); - changed = true; - } - } - if (surface.ceiling != null) { - changed |= applyCeiling(chunk, cursor, x, z, world.getMaxY(), - world.getMinY(), surface.ceiling); + if (ceilingY >= world.getMinY()) { + changed |= applyCeiling(chunk, world, config, cursor, x, z, ceilingY); } } } return changed; } - private static boolean applyCeiling(ChunkAccess chunk, BlockPos.MutableBlockPos cursor, - int x, int z, int maxY, int minY, BlockState ceiling) { - for (int y = maxY - 1; y >= minY; y--) { - cursor.set(x, y, z); - BlockState state = chunk.getBlockState(cursor); - if (state.isAir() || !state.getFluidState().isEmpty()) continue; - if (!replaceable(state)) return false; - chunk.setBlockState(cursor, ceiling, 0); - return true; + private static int findOpenGround(ChunkAccess chunk, BlockPos.MutableBlockPos cursor, + int x, int z, int localX, int localZ, int minY) { + int y = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, localX, localZ); + while (y >= minY && open(chunk.getBlockState(cursor.set(x, y, z)))) y--; + return y; + } + + private static long findCeilingAndGround(ChunkAccess chunk, + BlockPos.MutableBlockPos cursor, int x, int z, int maxY, int minY) { + int y = maxY - 1; + while (y >= minY && open(chunk.getBlockState(cursor.set(x, y, z)))) y--; + if (y < minY) return pack(Integer.MIN_VALUE, Integer.MIN_VALUE); + while (y >= minY && !open(chunk.getBlockState(cursor.set(x, y, z)))) y--; + int ceilingY = y + 1; + while (y >= minY && open(chunk.getBlockState(cursor.set(x, y, z)))) y--; + return pack(ceilingY, y); + } + + private static boolean applyGround(ChunkAccess chunk, WorldGenLevel world, + BakedBiomeWorldgen config, BlockPos.MutableBlockPos cursor, + int x, int z, int y, int minY) { + cursor.set(x, y, z); + BlockState source = chunk.getBlockState(cursor); + if (!replaceable(source)) return false; + Surface surface = config.surfaces.get(world.getBiome(cursor)); + if (surface == null) return false; + cursor.set(x, y + 1, z); + boolean underwater = !chunk.getBlockState(cursor).getFluidState().isEmpty(); + BlockState top = underwater && surface.underwater != null + ? surface.underwater : surface.top; + boolean changed = false; + cursor.set(x, y, z); + if (top != null) { + chunk.setBlockState(cursor, top, 0); + changed = true; + } + if (surface.filler != null) { + for (int depth = 1; depth <= surface.fillerDepth && y - depth >= minY; depth++) { + cursor.set(x, y - depth, z); + if (!replaceable(chunk.getBlockState(cursor))) break; + chunk.setBlockState(cursor, surface.filler, 0); + changed = true; + } } - return false; + return changed; + } + + private static boolean applyCeiling(ChunkAccess chunk, WorldGenLevel world, + BakedBiomeWorldgen config, BlockPos.MutableBlockPos cursor, + int x, int z, int ceilingY) { + cursor.set(x, ceilingY, z); + BlockState source = chunk.getBlockState(cursor); + if (!replaceable(source)) return false; + Surface surface = config.surfaces.get(world.getBiome(cursor)); + if (surface == null || surface.ceiling == null) return false; + chunk.setBlockState(cursor, surface.ceiling, 0); + return true; + } + + private static boolean open(BlockState state) { + return state.isAir() || !state.getFluidState().isEmpty(); + } + + private static long pack(int high, int low) { + return ((long) high << 32) | (low & 0xFFFFFFFFL); } private static boolean replaceable(BlockState state) { 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 4a3b7284..b0aa7c7c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java @@ -117,8 +117,9 @@ public boolean place(FeaturePlaceContext context) { for (BakedDeposit deposit : deposits) { if (!deposit.acceptsBiome(biome)) continue; if (!geomeClassified && deposit.usesGeomeWeights && config != null) { + Identifier biomeId = biome.unwrapKey().map(ResourceKey::identifier).orElse(null); geome = classifier(dimension, world.getSeed(), config).classifyColumn( - biome.value(), centerX, centerZ, scratch.geomeValues(config.geomeCount())); + biome.value(), biomeId, centerX, centerZ, scratch.geomeValues(config.geomeCount())); geomeClassified = true; } double frequency = geome < 0 ? deposit.frequency : deposit.frequency * deposit.geomeWeights[geome]; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/FormationSettings.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/FormationSettings.java index 87c58224..45926ccc 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/FormationSettings.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/FormationSettings.java @@ -26,12 +26,12 @@ public static Algorithm fromConfigName(String name) { } public enum Preset { - TINY("tiny", 64.0D, 25.0D, 128.0D, 1, 15.0D, 1, 0.00D, 12.0D, 32.0D, 0.0D, 0), - SMALL("small", 128.0D, 50.0D, 192.0D, 3, 30.0D, 2, 0.50D, 24.0D, 48.0D, 4.0D, 1), - AVERAGE("average", 256.0D, 100.0D, 256.0D, 8, 60.0D, 4, 0.85D, 48.0D, 64.0D, 12.0D, 2), - LARGE("large", 512.0D, 200.0D, 384.0D, 28, 90.0D, 5, 0.95D, 128.0D, 96.0D, 24.0D, 3), - HUGE("huge", 1024.0D, 640.0D, 512.0D, 128, 120.0D, 6, 1.00D, 288.0D, 128.0D, 48.0D, 4), - CUSTOM("custom", 256.0D, 100.0D, 256.0D, 8, 60.0D, 4, 0.85D, 48.0D, 64.0D, 12.0D, 2); + TINY("tiny", 64.0D, 25.0D, 128.0D, 1, 15.0D, 1, 0.00D, 12.0D, 48.0D, 4.0D, 1), + SMALL("small", 128.0D, 50.0D, 192.0D, 3, 30.0D, 2, 0.50D, 24.0D, 64.0D, 12.0D, 2), + AVERAGE("average", 256.0D, 100.0D, 256.0D, 8, 60.0D, 4, 0.85D, 48.0D, 96.0D, 24.0D, 3), + LARGE("large", 512.0D, 200.0D, 384.0D, 28, 90.0D, 5, 0.95D, 128.0D, 128.0D, 48.0D, 4), + HUGE("huge", 1024.0D, 640.0D, 512.0D, 128, 120.0D, 6, 1.00D, 288.0D, 192.0D, 96.0D, 5), + CUSTOM("custom", 256.0D, 100.0D, 256.0D, 8, 60.0D, 4, 0.85D, 48.0D, 96.0D, 24.0D, 3); final String configName; final double stratumWavelength; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java index 1ff5b44c..80a9763e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java @@ -1,5 +1,7 @@ package zone.moddev.mc.orespawn.worldgen; +import java.util.ArrayList; +import java.util.List; import java.util.Random; import zone.moddev.mc.orespawn.worldgen.math.PerlinNoise2D; @@ -15,8 +17,13 @@ import net.minecraft.core.Holder; import net.minecraft.resources.Identifier; import net.minecraft.world.level.biome.Biome; +import net.minecraft.core.registries.BuiltInRegistries; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; public class Geology { + private static final Logger LOGGER = LogManager.getLogger(); private final PerlinNoise2D geomeNoiseLayer; private final PerlinNoise2D rockNoiseLayer; private final short[] whiteNoiseArray; @@ -24,10 +31,32 @@ public class Geology { private final BlockState[] metamorphicStones; private final BlockState[] sedimentaryStones; private final int layerThickness; + private final boolean realisticCoalLayers; public Geology(long seed, double geomeSize, double rockLayerSize, int layerThickness, BakedGeomeConfig config) { + this(seed, geomeSize, rockLayerSize, layerThickness, false, + config.statesForFamily(RockFamily.IGNEOUS_INTRUSIVE, RockFamily.IGNEOUS_VOLCANIC), + config.statesForFamily(RockFamily.METAMORPHIC), + config.statesForFamily(RockFamily.SEDIMENTARY)); + } + + Geology(long seed, WorldGeologyProfile profile, BakedGeomeConfig config) { + this(seed, profile.cyanoGeomeSize(), profile.cyanoRockLayerNoise(), + profile.cyanoLayerThickness(), profile.cyanoRealisticCoalLayers(), + resolveRockOrder(profile, "igneous_rocks", + config.statesForFamily(RockFamily.IGNEOUS_INTRUSIVE, RockFamily.IGNEOUS_VOLCANIC)), + resolveRockOrder(profile, "metamorphic_rocks", + config.statesForFamily(RockFamily.METAMORPHIC)), + resolveRockOrder(profile, "sedimentary_rocks", + config.statesForFamily(RockFamily.SEDIMENTARY))); + } + + Geology(long seed, double geomeSize, double rockLayerSize, int layerThickness, + boolean realisticCoalLayers, BlockState[] igneousStones, + BlockState[] metamorphicStones, BlockState[] sedimentaryStones) { this.layerThickness = layerThickness; + this.realisticCoalLayers = realisticCoalLayers; int rockLayerUndertones = 4; int undertoneMultiplier = 1 << (rockLayerUndertones - 1); geomeNoiseLayer = new PerlinNoise2D(~seed, 128, (float) geomeSize, 2); @@ -40,9 +69,9 @@ public Geology(long seed, double geomeSize, double rockLayerSize, int layerThick whiteNoiseArray[i] = (short) random.nextInt(0x7FFF); } - igneousStones = config.statesForFamily(RockFamily.IGNEOUS_INTRUSIVE, RockFamily.IGNEOUS_VOLCANIC); - metamorphicStones = config.statesForFamily(RockFamily.METAMORPHIC); - sedimentaryStones = config.statesForFamily(RockFamily.SEDIMENTARY); + this.igneousStones = igneousStones; + this.metamorphicStones = metamorphicStones; + this.sedimentaryStones = sedimentaryStones; } public Block getStoneAt(int x, int y, int z) { @@ -83,9 +112,11 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer for (; y >= chunk.getMinY(); y--) { cursor.set(x, y, z); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current)) { - StoneReplacer.setRockState(chunk, cursor, current, - pickReplacement(baseRockVal, geomeBase, y)); + if (terrain.isReplaceable(current) + || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) { + BlockState replacement = pickReplacement(baseRockVal, geomeBase, y); + if (current.equals(replacement)) continue; + StoneReplacer.setRockState(chunk, cursor, current, replacement); changed = true; } } @@ -133,4 +164,30 @@ private BlockState pickStateFromList(int value, BlockState[] list) { return list[whiteNoiseArray[(value / layerThickness) & 0xFF] % list.length]; } + static BlockState[] resolveRockOrder(WorldGeologyProfile profile, String key, + BlockState[] fallback) { + if (!profile.hasCyanoRockOrder(key)) return fallback; + List states = new ArrayList<>(); + for (String idText : profile.cyanoRockOrder(key)) { + try { + Identifier id = Identifier.parse(idText); + Block block = BuiltInRegistries.BLOCK.containsKey(id) + ? BuiltInRegistries.BLOCK.getValue(id) : null; + if (block != null && block != Blocks.AIR) { + states.add(block.defaultBlockState()); + } else { + LOGGER.warn("Legacy Mineralogy rock '{}' is not registered and will be omitted", id); + } + } catch (RuntimeException e) { + LOGGER.warn("Legacy Mineralogy rock registry name '{}' is invalid and will be omitted", idText); + } + } + if (states.isEmpty()) { + LOGGER.warn("No snapshotted legacy Mineralogy rocks for '{}' are registered; " + + "using the matching provider family as a safe fallback", key); + return fallback; + } + return states.toArray(new BlockState[states.size()]); + } + } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java index 077f43e2..011c244c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java @@ -10,6 +10,7 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.LinkedHashMap; @@ -287,11 +288,14 @@ private static BakedGeomeConfig bake(JsonObject root, Identifier dimension) { return null; } Map biomeWeights = bakeBiomeWeights(geomeIndexes, biomeRules, dictionaryRules); + Map biomeWeightsById = bakeBiomeIdentifierWeights(geomeIndexes, biomeRules); - LOGGER.info("Baked OreSpawn geome config for '{}' with {} geomes, {} rock entries, {} biome profiles, and {} formations", - dimension, geomes.length, rocks.length, biomeWeights.size(), formations.algorithm.configName); + LOGGER.info("Baked OreSpawn geome config for '{}' with {} geomes, {} rock entries, " + + "{} resolved biome profiles, {} identifier profiles, and {} formations", + dimension, geomes.length, rocks.length, biomeWeights.size(), biomeWeightsById.size(), + formations.algorithm.configName); return new BakedGeomeConfig(geomes, geomeScale, biomeInfluence, regionalNoiseInfluence, - boundaryNoiseInfluence, biomeWeights, rocks, formations); + boundaryNoiseInfluence, biomeWeights, biomeWeightsById, rocks, formations); } private static JsonObject applyFreshWorldTemplate(JsonObject root) { @@ -445,16 +449,16 @@ private static FormationSettings readFormationSettings(JsonObject root) { : stableLayers ? waviness.stableWavinessAmplitude : waviness.wavinessAmplitude; double edgeWavelength = stableLayers ? irregularity == Preset.CUSTOM - ? getBoundedDouble(custom, "edge_wavelength", 64.0D, 8.0D, 512.0D) + ? getBoundedDouble(custom, "edge_wavelength", 96.0D, 8.0D, 512.0D) : irregularity.stableEdgeWavelength : 64.0D; double edgeAmplitude = !stableLayers ? 0.0D : irregularity == Preset.CUSTOM - ? getBoundedDouble(custom, "edge_amplitude", 12.0D, 0.0D, 256.0D) + ? getBoundedDouble(custom, "edge_amplitude", 24.0D, 0.0D, 256.0D) : irregularity.stableEdgeAmplitude; int edgeOctaves = irregularity == Preset.CUSTOM - ? getBoundedInt(custom, "edge_octaves", stableLayers ? 2 : 4, 1, 8) + ? getBoundedInt(custom, "edge_octaves", stableLayers ? 3 : 4, 1, 8) : stableLayers ? irregularity.stableEdgeOctaves : irregularity.edgeOctaves; double formationContinuity = continuity == Preset.CUSTOM ? getBoundedDouble(custom, "continuity", 0.85D, 0.0D, 1.0D) @@ -1015,13 +1019,13 @@ private static Map readWorldgenAliases(JsonObject root) private static Map bakeBiomeWeights(Map geomeIndexes, Map biomeRules, Map dictionaryRules) { Map result = new IdentityHashMap<>(); - for (Biome biome : zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.values()) { + for (Biome biome : BiomeRegistryAccess.values()) { double[] weights = new double[geomeIndexes.size()]; for (int i = 0; i < weights.length; i++) { weights[i] = 1.0D; } - Identifier biomeId = zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.id(biome); + Identifier biomeId = BiomeRegistryAccess.id(biome); if (biomeId != null) { merge(weights, biomeRules.get(biomeId.toString())); for (String type : BiomeTypeCompatibility.types(biome)) { @@ -1034,11 +1038,33 @@ private static Map bakeBiomeWeights(Map geomeI return result; } + static Map bakeBiomeIdentifierWeights(Map geomeIndexes, + Map biomeRules) { + Map result = new LinkedHashMap<>(); + for (Entry entry : biomeRules.entrySet()) { + try { + Identifier biomeId = Identifier.parse(entry.getKey()); + double[] weights = new double[geomeIndexes.size()]; + Arrays.fill(weights, 1.0D); + merge(weights, entry.getValue()); + applyBiomeHeuristic(weights, geomeIndexes, biomeId, Float.NaN, Float.NaN); + result.put(biomeId, weights); + } catch (RuntimeException e) { + LOGGER.warn("Ignoring invalid OreSpawn biome rule ID '{}'", entry.getKey()); + } + } + return result; + } + private static void applyBiomeHeuristic(double[] weights, Map geomeIndexes, Identifier biomeId, Biome biome) { + applyBiomeHeuristic(weights, geomeIndexes, biomeId, + biome.getBaseTemperature(), biome.getModifiedClimateSettings().downfall()); + } + + private static void applyBiomeHeuristic(double[] weights, Map geomeIndexes, + Identifier biomeId, float temperature, float downfall) { String biomeName = biomeId == null ? "" : biomeId.getPath(); - float temperature = biome.getBaseTemperature(); - float downfall = biome.getModifiedClimateSettings().downfall(); if (biomeName.contains("ocean") || biomeName.contains("river") || biomeName.contains("beach") || biomeName.contains("shore") || biomeName.contains("coast") @@ -1521,8 +1547,8 @@ private static JsonObject defaultFormationConfig() { formations.addProperty("edge_irregularity", Preset.AVERAGE.configName); formations.addProperty("formation_continuity", Preset.AVERAGE.configName); formations.add("custom", customFormationConfig( - 256.0D, 100.0D, 8, 48.0D, 2, 0.85D, - 256.0D, 64.0D, 12.0D)); + 256.0D, 100.0D, 8, 48.0D, 3, 0.85D, + 256.0D, 96.0D, 24.0D)); return formations; } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeDistributionSampler.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeDistributionSampler.java index 92031dfb..4bbd1978 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeDistributionSampler.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeDistributionSampler.java @@ -49,7 +49,7 @@ public static String sample(long seed, Iterable biomes, int columnsPerBio long selectionSignature = 0xCBF29CE484222325L; int step = Math.max(1, yStep); for (Biome biome : biomes) { - Identifier biomeId = zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.id(biome); + Identifier biomeId = BiomeRegistryAccess.id(biome); if (!isOverworldGeologyBiome(biomeId, biome)) { continue; } @@ -132,15 +132,13 @@ public static String sampleTerrain(long seed, Path path) throws IOException { } Biome[] biomePalette = new Biome[paletteSize]; + Identifier[] biomeIds = new Identifier[paletteSize]; for (int index = 0; index < paletteSize; index++) { int length = input.readUnsignedShort(); byte[] encoded = new byte[length]; input.readFully(encoded); - Identifier biomeId = Identifier.parse(new String(encoded, StandardCharsets.UTF_8)); - biomePalette[index] = zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.get(biomeId); - if (biomePalette[index] == null) { - throw new IOException("Unknown biome " + biomeId + " in " + path); - } + biomeIds[index] = Identifier.parse(new String(encoded, StandardCharsets.UTF_8)); + biomePalette[index] = BiomeRegistryAccess.get(biomeIds[index]); } int height = maxY - minY + 1; @@ -153,9 +151,10 @@ public static String sampleTerrain(long seed, Path path) throws IOException { } input.readFully(rockMask); Biome biome = biomePalette[biomeIndex]; + Identifier biomeId = biomeIds[biomeIndex]; int x = minX + xOffset; int z = minZ + zOffset; - int geomeIndex = geology.classifyColumn(biome, x, z, regionalValues); + int geomeIndex = geology.classifyColumn(biome, biomeId, x, z, regionalValues); int stratumOffset = geology.stratumOffsetAt(x, z); long formationRegion = geology.formationRegionAt(x, z); add(geomeCounts, config.geomeName(geomeIndex)); @@ -163,7 +162,7 @@ public static String sampleTerrain(long seed, Path path) throws IOException { if ((rockMask[yIndex >>> 3] & (1 << (yIndex & 7))) == 0) { continue; } - Block block = geology.getStoneAt(geomeIndex, stratumOffset, formationRegion, + Block block = geology.getStoneAt(geomeIndex, regionalValues, stratumOffset, formationRegion, x, minY + yIndex, z); Identifier id = BuiltInRegistries.BLOCK.getKey(block); String rockId = id == null ? "" : id.toString(); @@ -267,7 +266,7 @@ private static void appendBiomeAudit(StringBuilder report, GeomeGeology geology, private static String biomeTypes(Identifier biomeId) { List names = new ArrayList<>(); - Biome biome = zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.get(biomeId); + Biome biome = BiomeRegistryAccess.get(biomeId); if (biome != null) names.addAll(BiomeTypeCompatibility.types(biome)); Collections.sort(names); return names.toString(); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java index 84841df5..fdda0ef3 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java @@ -18,6 +18,7 @@ import net.minecraft.world.level.block.state.BlockState; public final class GeomeGeology { + private static final double GEOME_TRANSITION_SCORE_WIDTH = 0.125D; private static final int[] LITHOLOGY_PHASES = { 0, 2, 1, 3, 3, 1, 2, 0, @@ -34,6 +35,7 @@ public final class GeomeGeology { private final short[] whiteNoiseArray; private final boolean[] globallyContinuousLayers; private final boolean[] regionallyVariedRocks; + private final int geomeTransitionPhase; private final int layerThickness; private final int formationRegionScale; private final int familyDiversitySlots; @@ -67,6 +69,7 @@ public GeomeGeology(long seed, BakedGeomeConfig config) { } Random random = new Random(seed ^ 0x5EEDBEEFL); + geomeTransitionPhase = new Random(seed ^ 0x47454F4D4554524EL).nextInt(256); whiteNoiseArray = new short[256]; for (int i = 0; i < whiteNoiseArray.length; i++) { whiteNoiseArray[i] = (short) random.nextInt(0x7FFF); @@ -109,7 +112,8 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer long formationRegion = formationRegionAt(x, z); if (stableLayers) { - changed |= replaceStableColumn(chunk, cursor, geomeIndex, baseRockValue, + int secondGeome = runnerUpGeome(regionalValues, geomeIndex); + changed |= replaceStableColumn(chunk, cursor, geomeIndex, secondGeome, regionalValues, baseRockValue, formationRegion, x, z, surfaceY, terrain); } else { for (int y = surfaceY; y >= chunk.getMinY(); y--) { @@ -131,11 +135,13 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer } private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos cursor, int geomeIndex, - int baseRockValue, long formationRegion, int x, int z, int surfaceY, + int secondGeome, double[] geomeScores, int baseRockValue, long formationRegion, int x, int z, int surfaceY, BakedTerrainDimension terrain) { int layerIndex = Math.floorDiv(baseRockValue + surfaceY, layerThickness); int layerStart = layerIndex * layerThickness; - BlockState replacement = pickStableReplacement(geomeIndex, formationRegion, layerIndex); + int layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, + layerIndex, geomeTransitionPhase); + BlockState replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); boolean changed = false; cursor.set(x, surfaceY, z); @@ -144,7 +150,9 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos if (stratum < layerStart) { layerIndex--; layerStart -= layerThickness; - replacement = pickStableReplacement(geomeIndex, formationRegion, layerIndex); + layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, + layerIndex, geomeTransitionPhase); + replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); } cursor.setY(y); BlockState current = chunk.getBlockState(cursor); @@ -159,7 +167,14 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos public Block getStoneAt(Biome biome, int x, int y, int z, int surfaceY) { double[] regionalValues = new double[config.geomeCount()]; int geomeIndex = classifyColumn(biome, x, z, regionalValues); - return pickReplacement(geomeIndex, stratumOffsetAt(x, z), formationRegionAt(x, z), x, y, z).getBlock(); + int stratumOffset = stratumOffsetAt(x, z); + long formationRegion = formationRegionAt(x, z); + if (stableLayers) { + int layerIndex = Math.floorDiv(stratumOffset + y, layerThickness); + geomeIndex = pickStableLayerGeome(regionalValues, geomeIndex, + runnerUpGeome(regionalValues, geomeIndex), layerIndex, geomeTransitionPhase); + } + return pickReplacement(geomeIndex, stratumOffset, formationRegion, x, y, z).getBlock(); } public String getGeomeName(Biome biome, int x, int z) { @@ -178,18 +193,24 @@ int classifyColumn(Biome biome, int x, int z, double[] regionalValues) { public ColumnSample sampleColumn(Biome biome, Identifier biomeId, int x, int z) { double[] regionalValues = new double[config.geomeCount()]; int geomeIndex = classifyColumn(biome, biomeId, x, z, regionalValues); - return new ColumnSample(geomeIndex, stratumOffsetAt(x, z), formationRegionAt(x, z), x, z); + return new ColumnSample(geomeIndex, runnerUpGeome(regionalValues, geomeIndex), regionalValues, + stratumOffsetAt(x, z), formationRegionAt(x, z), x, z); } public final class ColumnSample { private final int geomeIndex; + private final int secondGeome; + private final double[] geomeScores; private final int stratumOffset; private final long formationRegion; private final int x; private final int z; - private ColumnSample(int geomeIndex, int stratumOffset, long formationRegion, int x, int z) { + private ColumnSample(int geomeIndex, int secondGeome, double[] geomeScores, + int stratumOffset, long formationRegion, int x, int z) { this.geomeIndex = geomeIndex; + this.secondGeome = secondGeome; + this.geomeScores = geomeScores; this.stratumOffset = stratumOffset; this.formationRegion = formationRegion; this.x = x; @@ -201,7 +222,13 @@ public String geomeName() { } public BlockState rockAt(int y) { - return pickReplacement(geomeIndex, stratumOffset, formationRegion, x, y, z); + int selectedGeome = geomeIndex; + if (stableLayers) { + int layerIndex = Math.floorDiv(stratumOffset + y, layerThickness); + selectedGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, + layerIndex, geomeTransitionPhase); + } + return pickReplacement(selectedGeome, stratumOffset, formationRegion, x, y, z); } public RockFamily familyAt(int y) { @@ -209,13 +236,13 @@ public RockFamily familyAt(int y) { } } - private int classifyColumn(Biome biome, Identifier biomeId, int x, int z, double[] regionalValues) { + int classifyColumn(Biome biome, Identifier biomeId, int x, int z, double[] regionalValues) { for (int i = 0; i < regionalValues.length; i++) { regionalValues[i] = regionalNoise.valueAt(x + config.noiseOffsetX[i], z + config.noiseOffsetZ[i]); } double boundary = boundaryNoise.valueAt(x, z); - return config.pickGeome(biome, biomeId, regionalValues, boundary); + return config.scoreGeomes(biome, biomeId, regionalValues, boundary); } private net.minecraft.world.level.block.state.BlockState pickReplacement(int geomeIndex, int baseRockValue, @@ -271,7 +298,13 @@ int stratumLayerAt(int x, int y, int z) { return Math.floorDiv(stratumOffsetAt(x, z) + y, layerThickness); } - Block getStoneAt(int geomeIndex, int stratumOffset, long formationRegion, int x, int y, int z) { + Block getStoneAt(int geomeIndex, double[] geomeScores, int stratumOffset, + long formationRegion, int x, int y, int z) { + if (stableLayers) { + int layerIndex = Math.floorDiv(stratumOffset + y, layerThickness); + geomeIndex = pickStableLayerGeome(geomeScores, geomeIndex, + runnerUpGeome(geomeScores, geomeIndex), layerIndex, geomeTransitionPhase); + } return pickReplacement(geomeIndex, stratumOffset, formationRegion, x, y, z).getBlock(); } @@ -300,6 +333,44 @@ private static int mixRegion(int cellX, int cellZ, int contour) { return hash ^ (hash >>> 16); } + static int pickStableLayerGeome(double[] geomeScores, int firstGeome, int secondGeome, + int layerIndex, int phase) { + if (firstGeome == secondGeome) { + return firstGeome; + } + int lowerGeome = Math.min(firstGeome, secondGeome); + int higherGeome = Math.max(firstGeome, secondGeome); + double higherFraction = 0.5D + ((geomeScores[higherGeome] - geomeScores[lowerGeome]) + / (2.0D * GEOME_TRANSITION_SCORE_WIDTH)); + if (higherFraction <= 0.0D) { + return lowerGeome; + } + if (higherFraction >= 1.0D) { + return higherGeome; + } + + // Bit reversal supplies an allocation-free low-discrepancy sequence. Nearby + // layers therefore cross a close geome boundary at different horizontal + // positions instead of moving as one full-height wall. + int pairPhase = (lowerGeome * 53) + (higherGeome * 97); + int layerBucket = (layerIndex + phase + pairPhase) & 0xFF; + int threshold = Integer.reverse(layerBucket) >>> 24; + return ((threshold + 0.5D) / 256.0D) < higherFraction ? higherGeome : lowerGeome; + } + + private static int runnerUpGeome(double[] geomeScores, int bestGeome) { + if (geomeScores.length < 2) { + return bestGeome; + } + int second = bestGeome == 0 ? 1 : 0; + for (int i = 0; i < geomeScores.length; i++) { + if (i != bestGeome && geomeScores[i] > geomeScores[second]) { + second = i; + } + } + return second; + } + private static double faciesFraction(double regionScale) { if (regionScale <= 100.0D) { return Math.max(0.0D, (regionScale - 50.0D) / 350.0D); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java index e10975a1..7d86dd4f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -10,6 +10,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Locale; @@ -94,6 +95,7 @@ static JsonObject migrateIfNeeded(Path target, JsonObject defaults, report.add("Original files were retained unchanged. Review registry IDs and biome/dimension warnings before deleting them."); if (!write(target, migrated)) return null; writeReport(config, report); + writeUpgradeReport(config, imported, report); LOGGER.info("Migrated {} legacy OreSpawn definitions into '{}'", imported, target); return migrated; } @@ -354,6 +356,44 @@ private static void writeReport(Path config, List lines) { } } + private static void writeUpgradeReport(Path config, int imported, List detail) { + List lines = new ArrayList<>(); + lines.add("OreSpawn 4.0.6.2601022 Upgrade Report"); + lines.add("================================"); + lines.add(""); + lines.add("RESULT: Legacy OreSpawn settings were imported into the OS4 profile."); + lines.add("- Spawn definitions imported: " + imported); + lines.add("- Detailed translation report: " + + config.resolve("orespawn-migration/migration-report.txt").toAbsolutePath()); + for (String entry : detail) { + if (entry.startsWith("Warning:") || entry.startsWith("Skipped") + || entry.startsWith("Clamped")) lines.add("- " + entry); + } + lines.add(""); + lines.add("Original legacy configuration files were retained unchanged."); + writeTextAtomically(config.resolve("orespawn-upgrade-report.txt"), lines); + } + + private static void writeTextAtomically(Path path, List lines) { + Path temporary = path.resolveSibling(path.getFileName().toString() + ".tmp"); + try { + Files.createDirectories(path.getParent()); + byte[] bytes = (String.join(System.lineSeparator(), lines) + System.lineSeparator()) + .getBytes(StandardCharsets.UTF_8); + if (Files.isRegularFile(path) && Arrays.equals(Files.readAllBytes(path), bytes)) return; + Files.write(temporary, bytes); + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + try { Files.deleteIfExists(temporary); } catch (IOException ignored) { } + LOGGER.warn("Could not write OreSpawn upgrade report '{}'", path, e); + } + } + private static JsonObject object(JsonObject root, String key) { if (!root.has(key) || !root.get(key).isJsonObject()) root.add(key, new JsonObject()); return root.getAsJsonObject(key); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java new file mode 100644 index 00000000..a10b3cc2 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -0,0 +1,630 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.resources.Identifier; +import net.minecraft.core.registries.BuiltInRegistries; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; + +/** + * Snapshots the exact Mineralogy geology contract used by an already-generated + * world before OreSpawn becomes responsible for that world's geology. + * + *

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

+ */ +final class LegacyMineralogyProfileMigration { + private static final Logger LOGGER = LogManager.getLogger(); + private static final String CFG_FILE = "mineralogy.cfg"; + private static final String TOML_FILE = "mineralogy-common.toml"; + + private static final List IGNEOUS_110 = list( + "mineralogy:diabase", "mineralogy:gabbro", "mineralogy:peridotite", + "mineralogy:basaltic_glass", "mineralogy:scoria", "mineralogy:tuff", + "mineralogy:andesite", "mineralogy:basalt", "mineralogy:diorite", + "mineralogy:granite", "mineralogy:rhyolite", "mineralogy:pegmatite", + "mineralogy:pumice"); + private static final List METAMORPHIC_110 = list( + "mineralogy:hornfels", "mineralogy:quartzite", "mineralogy:novaculite", + "mineralogy:slate", "mineralogy:schist", "mineralogy:gneiss", + "mineralogy:phyllite", "mineralogy:amphibolite"); + private static final List SEDIMENTARY_110_BEFORE_COAL = list( + "mineralogy:siltstone", "mineralogy:shale", "mineralogy:conglomerate", + "mineralogy:dolomite", "mineralogy:limestone", "mineralogy:marble", + "minecraft:sandstone"); + private static final List SEDIMENTARY_110_AFTER_COAL = list( + "mineralogy:chert", "mineralogy:gypsum", "mineralogy:chalk", + "mineralogy:rock_salt"); + + private static final List IGNEOUS_112 = list( + "mineralogy:andesite", "mineralogy:basalt", "mineralogy:diorite", + "mineralogy:granite", "mineralogy:rhyolite", "mineralogy:pegmatite", + "mineralogy:diabase", "mineralogy:gabbro", "mineralogy:peridotite", + "mineralogy:basaltic_glass", "mineralogy:scoria", "mineralogy:tuff", + "mineralogy:pumice"); + private static final List METAMORPHIC_112 = list( + "mineralogy:slate", "mineralogy:schist", "mineralogy:gneiss", + "mineralogy:phyllite", "mineralogy:amphibolite", "mineralogy:hornfels", + "mineralogy:quartzite", "mineralogy:novaculite"); + private static final List SEDIMENTARY_112 = list( + "mineralogy:shale", "mineralogy:conglomerate", "mineralogy:dolomite", + "mineralogy:limestone", "mineralogy:siltstone", "mineralogy:marble", + "minecraft:sandstone", "mineralogy:chert", "mineralogy:gypsum", + "mineralogy:chalk", "mineralogy:rock_salt", "mineralogy:rock_salt"); + + /* Exact registration order used by published Mineralogy 5.0.1 through 5.4.0. */ + private static final List IGNEOUS_5 = list( + "mineralogy:andesite", "mineralogy:basalt", "mineralogy:diorite", + "mineralogy:granite", "mineralogy:rhyolite", "mineralogy:pegmatite", + "mineralogy:diabase", "mineralogy:gabbro", "mineralogy:peridotite", + "mineralogy:basaltic_glass", "mineralogy:scoria", "mineralogy:tuff", + "mineralogy:pumice"); + private static final List METAMORPHIC_5 = list( + "mineralogy:marble", "mineralogy:slate", "mineralogy:schist", + "mineralogy:gneiss", "mineralogy:phyllite", "mineralogy:amphibolite", + "mineralogy:hornfels", "mineralogy:quartzite", "mineralogy:novaculite"); + private static final List SEDIMENTARY_5 = list( + "mineralogy:shale", "mineralogy:conglomerate", "mineralogy:dolomite", + "mineralogy:limestone", "mineralogy:siltstone", "mineralogy:rock_salt", + "minecraft:sandstone", "mineralogy:chert", "mineralogy:gypsum", + "mineralogy:chalk"); + + private LegacyMineralogyProfileMigration() { + } + + static WorldGeologyProfile migrateIfNeeded(Path worldRoot, Path configDirectory, + WorldGeologyProfile installedPackProfile) { + // A per-world OreSpawn profile is authoritative. Keep this guard here as + // well as in the server lifecycle caller so future call sites cannot + // accidentally reclassify an established OS4 world from stale files. + if (Files.isRegularFile(worldRoot.resolve("serverconfig") + .resolve("orespawn-worldgen.json"))) return null; + if (!hasGeneratedOverworldChunks(worldRoot)) return null; + + MineralogyIdentity identity = legacyMineralogyIdentity(worldRoot); + if (identity == null) return null; + + Lineage lineage = Lineage.forVersion(identity.version); + Path configPath = configDirectory.resolve(lineage == Lineage.MINERALOGY_5 + ? TOML_FILE : CFG_FILE); + ConfigValues values = lineage == Lineage.MINERALOGY_5 + ? readToml(configPath) : readForgeCfg(configPath); + boolean configFound = Files.isRegularFile(configPath); + List warnings = new ArrayList<>(); + if (!configFound) { + Path other = configDirectory.resolve(lineage == Lineage.MINERALOGY_5 + ? CFG_FILE : TOML_FILE); + if (Files.isRegularFile(other)) { + warnings.add("Found " + other.getFileName() + " but saved world metadata selects " + + lineage.label + "; published " + lineage.label + " defaults were used."); + } + } + + boolean hybridConfig = values.scalars.containsKey("place_mineralogy_rock") + && values.scalars.containsKey("realistic_coal_layers"); + boolean enabled = lineage == Lineage.MINERALOGY_110 + ? true : bool(values, "place_mineralogy_rock", true); + boolean realisticCoal = lineage == Lineage.MINERALOGY_110 + && bool(values, "realistic_coal_layers", false); + int geomeSize = integer(values, "geome_size", 100, 4, Short.MAX_VALUE); + double rockLayerNoise = decimal(values, "rock_layer_noise", 32.0D, + 1.0D, Short.MAX_VALUE); + int layerThickness = integer(values, "rock_layer_thickness", 8, 1, 255); + GeologyMode engine = lineage == Lineage.MINERALOGY_5 + ? geologyMode(values, warnings) : GeologyMode.LEGACY; + + List igneous = effectiveList(lineage.igneous, values, + "igneous_whitelist", "igneous_blacklist", lineage == Lineage.MINERALOGY_5); + List metamorphic = effectiveList(lineage.metamorphic, values, + "metamorphic_whitelist", "metamorphic_blacklist", lineage == Lineage.MINERALOGY_5); + List sedimentaryBase; + if (lineage == Lineage.MINERALOGY_110) { + sedimentaryBase = new ArrayList<>(SEDIMENTARY_110_BEFORE_COAL); + if (realisticCoal) sedimentaryBase.add("minecraft:coal_ore"); + sedimentaryBase.addAll(SEDIMENTARY_110_AFTER_COAL); + } else { + sedimentaryBase = new ArrayList<>(lineage.sedimentary); + } + List sedimentary = effectiveList(sedimentaryBase, values, + "sedimentary_whitelist", "sedimentary_blacklist", + lineage == Lineage.MINERALOGY_5); + + JsonObject root = installedPackProfile.rootCopy(); + root.addProperty("geology_mode", engine.name().toLowerCase(Locale.ROOT)); + JsonObject cyano = root.has("cyano") && root.get("cyano").isJsonObject() + ? root.getAsJsonObject("cyano") : new JsonObject(); + cyano.addProperty("enabled", enabled); + cyano.addProperty("geome_size", geomeSize); + cyano.addProperty("rock_layer_noise", rockLayerNoise); + cyano.addProperty("rock_layer_thickness", layerThickness); + cyano.addProperty("realistic_coal_layers", realisticCoal); + cyano.addProperty("migrated_from", "mineralogy-" + identity.version); + cyano.addProperty("legacy_lineage", lineage.label); + cyano.addProperty("legacy_engine", engine.name().toLowerCase(Locale.ROOT)); + cyano.addProperty("legacy_metadata_source", identity.sourceFile); + cyano.addProperty("legacy_config_source", configPath.toAbsolutePath().toString()); + cyano.addProperty("legacy_config_found", configFound); + cyano.addProperty("hybrid_config", hybridConfig); + cyano.add("igneous_rocks", array(igneous)); + cyano.add("metamorphic_rocks", array(metamorphic)); + cyano.add("sedimentary_rocks", array(sedimentary)); + for (String key : LIST_KEYS) cyano.add(key, array(values.list(key))); + root.add("cyano", cyano); + + writeUpgradeReport(worldRoot, configPath, identity, lineage, engine, + configFound, hybridConfig, enabled, geomeSize, rockLayerNoise, + layerThickness, realisticCoal, values, igneous, metamorphic, + sedimentary, warnings); + + LOGGER.info("Existing Mineralogy {} world detected from {}; pinned OreSpawn to {} " + + "behavior (engine={}, enabled={}, geomeSize={}, layerNoise={}, " + + "layerThickness={}, realisticCoal={}, configFound={})", + identity.version, identity.sourceFile, lineage.label, engine, enabled, + geomeSize, rockLayerNoise, layerThickness, realisticCoal, configFound); + return WorldGeologyProfile.fromJson(root, installedPackProfile); + } + + private static void writeUpgradeReport(Path worldRoot, Path configPath, + MineralogyIdentity identity, Lineage lineage, GeologyMode engine, + boolean configFound, boolean hybridConfig, boolean enabled, + int geomeSize, double rockLayerNoise, int layerThickness, + boolean realisticCoal, ConfigValues values, List igneous, + List metamorphic, List sedimentary, List warnings) { + Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); + List missing = missingBlocks(igneous, metamorphic, sedimentary); + List lines = new ArrayList<>(); + lines.add("OreSpawn 4.0.6.2601022 Upgrade Report"); + lines.add("================================"); + lines.add(""); + lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); + lines.add(enabled + ? "Geology remains on the " + engineLabel(engine) + " using " + + lineage.label + " behavior." + : "Legacy Mineralogy geology was disabled and remains disabled for this world."); + lines.add("This prevents an implicit settings change between old and newly generated chunks."); + lines.add(""); + lines.add("Legacy world detection"); + lines.add("- Saved mod metadata: " + identity.sourceFile); + lines.add("- Saved Mineralogy version: " + identity.version); + lines.add("- Selected config lineage: " + lineage.label); + lines.add("- Selected engine: " + engine.name()); + lines.add("- Hybrid 1.10/1.12 keys found: " + hybridConfig); + lines.add(""); + lines.add("Legacy Mineralogy configuration"); + lines.add("- Source: " + configPath.toAbsolutePath()); + lines.add("- Source file found: " + (configFound ? "yes" + : "no; published " + lineage.label + " defaults used")); + lines.add("- Geology enabled: " + enabled); + lines.add("- Geome size: " + geomeSize); + lines.add("- Rock layer noise: " + rockLayerNoise); + lines.add("- Rock layer thickness: " + layerThickness); + lines.add("- Realistic coal layers: " + realisticCoal + + (lineage == Lineage.MINERALOGY_110 ? "" : " (not used by this lineage)")); + for (String key : LIST_KEYS) { + lines.add("- " + key + " (" + values.list(key).size() + "): " + + String.join(", ", values.list(key))); + } + lines.add(""); + lines.add("Effective rock outputs"); + lines.add("- Igneous order (" + igneous.size() + "): " + String.join(", ", igneous)); + lines.add("- Metamorphic order (" + metamorphic.size() + "): " + + String.join(", ", metamorphic)); + lines.add("- Sedimentary order (" + sedimentary.size() + "): " + + String.join(", ", sedimentary)); + lines.add(""); + if (missing.isEmpty() && warnings.isEmpty()) { + lines.add("WARNINGS: None. Every preserved rock ID is registered."); + } else { + lines.add("WARNINGS:"); + for (String warning : warnings) lines.add("- " + warning); + for (String id : missing) lines.add("- Rock ID is not currently registered: " + id); + } + lines.add(""); + lines.add("OreSpawn did not rewrite the source Mineralogy configuration or existing chunks."); + lines.add("The generated OreSpawn world profile and this report are written atomically and are byte-stable on reload."); + lines.add("To change this world's geology later, make that choice explicitly and expect a generation seam."); + writeTextAtomically(report, lines); + } + + private static String engineLabel(GeologyMode engine) { + return engine == GeologyMode.LEGACY ? "Cyano layer engine" : "Mineralogy geome engine"; + } + + @SafeVarargs + private static List missingBlocks(List... families) { + Set missing = new LinkedHashSet<>(); + for (List family : families) { + for (String idText : family) { + try { + Identifier id = Identifier.parse(idText); + if (!BuiltInRegistries.BLOCK.containsKey(id)) missing.add(id.toString()); + } catch (RuntimeException e) { + missing.add(idText + " (invalid registry name)"); + } + } + } + return new ArrayList<>(missing); + } + + private static void writeTextAtomically(Path report, List lines) { + Path temporary = report.resolveSibling(report.getFileName().toString() + ".tmp"); + try { + Files.createDirectories(report.getParent()); + byte[] data = (String.join(System.lineSeparator(), lines) + System.lineSeparator()) + .getBytes(StandardCharsets.UTF_8); + if (Files.isRegularFile(report) && Arrays.equals(Files.readAllBytes(report), data)) return; + Files.write(temporary, data); + try { + Files.move(temporary, report, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, report, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + try { Files.deleteIfExists(temporary); } catch (IOException ignored) { } + LOGGER.warn("Could not write legacy Mineralogy upgrade report '{}'", report, e); + } + } + + private static boolean hasGeneratedOverworldChunks(Path worldRoot) { + Path regions = worldRoot.resolve("region"); + if (!Files.isDirectory(regions)) return false; + try (DirectoryStream files = Files.newDirectoryStream(regions, "r.*.*.mca")) { + return files.iterator().hasNext(); + } catch (IOException e) { + LOGGER.warn("Could not inspect existing world regions in '{}'", regions, e); + return false; + } + } + + private static MineralogyIdentity legacyMineralogyIdentity(Path worldRoot) { + for (String fileName : new String[] { "level.dat", "level.dat_old" }) { + Path levelDat = worldRoot.resolve(fileName); + if (!Files.isRegularFile(levelDat)) continue; + try (FileInputStream input = new FileInputStream(levelDat.toFile())) { + CompoundTag root = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); + MineralogyIdentity identity = identity(root, fileName); + if (identity != null) return identity.legacy ? identity : null; + } catch (IOException | RuntimeException e) { + LOGGER.warn("Could not inspect '{}' for legacy Mineralogy metadata", levelDat, e); + } + } + return null; + } + + private static MineralogyIdentity identity(CompoundTag root, String sourceFile) { + for (ModListPath path : MOD_LIST_PATHS) { + CompoundTag container = root.getCompoundOrEmpty(path.compound); + ListTag mods = container.getListOrEmpty(path.list); + for (int i = 0; i < mods.size(); i++) { + CompoundTag mod = mods.getCompoundOrEmpty(i); + String id = firstNonBlank(mod.getStringOr("ModId", ""), mod.getStringOr("modid", "")); + if (!"mineralogy".equalsIgnoreCase(id)) continue; + String version = firstNonBlank(mod.getStringOr("ModVersion", ""), mod.getStringOr("version", "")).trim(); + return new MineralogyIdentity(version.isEmpty() ? "legacy" : version, + sourceFile + " (" + path.compound + "/" + path.list + ")", + isLegacyVersion(version)); + } + } + return null; + } + + private static boolean isLegacyVersion(String version) { + if (version == null || version.trim().isEmpty() || "legacy".equalsIgnoreCase(version)) return true; + List parts = versionParts(version); + return !parts.isEmpty() && (parts.get(0) == 3 || parts.get(0) == 5); + } + + private static List versionParts(String version) { + List result = new ArrayList<>(); + if (version == null) return result; + for (String text : version.split("[^0-9]+")) { + if (text.isEmpty()) continue; + try { result.add(Integer.parseInt(text)); } + catch (NumberFormatException ignored) { } + } + return result; + } + + private static ConfigValues readForgeCfg(Path path) { + ConfigValues values = new ConfigValues(); + if (!Files.isRegularFile(path)) return values; + try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (trimmed.length() < 4 || trimmed.charAt(1) != ':') continue; + char type = Character.toUpperCase(trimmed.charAt(0)); + if (type != 'B' && type != 'I' && type != 'D' && type != 'S') continue; + int equals = trimmed.indexOf('=', 2); + if (equals <= 2) continue; + String key = normalizeKey(trimmed.substring(2, equals)); + String value = trimmed.substring(equals + 1).trim(); + if (isListKey(key)) values.lists.put(key, parseDelimitedList(value, ";")); + else values.scalars.put(key, value); + } + } catch (IOException e) { + LOGGER.warn("Could not read legacy Mineralogy configuration '{}'; using published defaults", path, e); + values.clear(); + } + return values; + } + + private static ConfigValues readToml(Path path) { + ConfigValues values = new ConfigValues(); + if (!Files.isRegularFile(path)) return values; + try { + List lines = Files.readAllLines(path, StandardCharsets.UTF_8); + for (int i = 0; i < lines.size(); i++) { + String line = stripTomlComment(lines.get(i)).trim(); + if (line.isEmpty() || line.startsWith("[")) continue; + int equals = indexOutsideQuotes(line, '='); + if (equals <= 0) continue; + String key = normalizeKey(line.substring(0, equals)); + String value = line.substring(equals + 1).trim(); + if (value.startsWith("[") && !arrayComplete(value)) { + StringBuilder joined = new StringBuilder(value); + while (++i < lines.size()) { + joined.append(' ').append(stripTomlComment(lines.get(i)).trim()); + if (arrayComplete(joined.toString())) break; + } + value = joined.toString(); + } + if (isListKey(key)) values.lists.put(key, parseTomlArray(value)); + else values.scalars.put(key, unquote(value)); + } + } catch (IOException e) { + LOGGER.warn("Could not read legacy Mineralogy TOML configuration '{}'; using published defaults", path, e); + values.clear(); + } + return values; + } + + private static String stripTomlComment(String line) { + boolean quoted = false; + boolean escaped = false; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (escaped) { escaped = false; continue; } + if (c == '\\' && quoted) { escaped = true; continue; } + if (c == '"') quoted = !quoted; + else if (c == '#' && !quoted) return line.substring(0, i); + } + return line; + } + + private static int indexOutsideQuotes(String text, char wanted) { + boolean quoted = false; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) quoted = !quoted; + if (c == wanted && !quoted) return i; + } + return -1; + } + + private static boolean arrayComplete(String text) { + boolean quoted = false; + int depth = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) quoted = !quoted; + if (!quoted && c == '[') depth++; + if (!quoted && c == ']') depth--; + } + return depth <= 0 && !quoted; + } + + private static List parseTomlArray(String value) { + String trimmed = value.trim(); + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return Collections.emptyList(); + trimmed = trimmed.substring(1, trimmed.length() - 1); + List result = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean quoted = false; + boolean escaped = false; + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (escaped) { current.append(c); escaped = false; continue; } + if (c == '\\' && quoted) { escaped = true; continue; } + if (c == '"') { quoted = !quoted; continue; } + if (c == ',' && !quoted) { + addConfiguredId(result, current.toString()); + current.setLength(0); + } else current.append(c); + } + addConfiguredId(result, current.toString()); + return result; + } + + private static List parseDelimitedList(String value, String delimiter) { + List result = new ArrayList<>(); + for (String entry : value.split(java.util.regex.Pattern.quote(delimiter), -1)) { + addConfiguredId(result, entry); + } + return result; + } + + private static void addConfiguredId(List result, String raw) { + String value = unquote(raw.trim()); + if (value.isEmpty()) return; + try { result.add(Identifier.parse(value).toString()); } + catch (RuntimeException e) { + LOGGER.warn("Ignoring invalid legacy Mineralogy rock registry name '{}'", value); + } + } + + private static String unquote(String value) { + String trimmed = value.trim(); + if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.substring(1, trimmed.length() - 1) + .replace("\\\"", "\"").replace("\\\\", "\\"); + } + return trimmed; + } + + private static GeologyMode geologyMode(ConfigValues values, List warnings) { + String configured = values.scalar("geology_mode"); + if (configured == null || configured.trim().isEmpty()) return GeologyMode.GEOME; + try { return GeologyMode.valueOf(configured.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException e) { + warnings.add("Invalid GEOLOGY_MODE '" + configured + "'; published GEOME default used."); + return GeologyMode.GEOME; + } + } + + private static List effectiveList(List defaults, ConfigValues values, + String whitelistKey, String blacklistKey, boolean deduplicateWhitelist) { + List result = new ArrayList<>(defaults); + for (String id : values.list(whitelistKey)) { + if (!deduplicateWhitelist || !result.contains(id)) result.add(id); + } + for (String id : values.list(blacklistKey)) result.remove(id); + return result; + } + + private static int integer(ConfigValues values, String key, int fallback, int min, int max) { + try { + int value = values.scalar(key) == null ? fallback : Integer.parseInt(values.scalar(key)); + return Math.max(min, Math.min(max, value)); + } catch (RuntimeException e) { return fallback; } + } + + private static double decimal(ConfigValues values, String key, + double fallback, double min, double max) { + try { + double value = values.scalar(key) == null ? fallback : Double.parseDouble(values.scalar(key)); + return Math.max(min, Math.min(max, value)); + } catch (RuntimeException e) { return fallback; } + } + + private static boolean bool(ConfigValues values, String key, boolean fallback) { + String value = values.scalar(key); + if (value == null) return fallback; + if ("true".equalsIgnoreCase(value)) return true; + if ("false".equalsIgnoreCase(value)) return false; + return fallback; + } + + private static JsonArray array(List values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String normalizeKey(String value) { + return unquote(value).trim().toLowerCase(Locale.ROOT).replace('-', '_'); + } + + private static boolean isListKey(String key) { + for (String candidate : LIST_KEYS) if (candidate.equals(key)) return true; + return false; + } + + private static String firstNonBlank(String first, String second) { + return first != null && !first.trim().isEmpty() ? first : second == null ? "" : second; + } + + private static List list(String... values) { + return Collections.unmodifiableList(Arrays.asList(values)); + } + + private static final List LIST_KEYS = list( + "igneous_whitelist", "igneous_blacklist", + "metamorphic_whitelist", "metamorphic_blacklist", + "sedimentary_whitelist", "sedimentary_blacklist"); + + private static final List MOD_LIST_PATHS = Arrays.asList( + new ModListPath("fml", "LoadingModList"), + new ModListPath("fml", "ModList"), + new ModListPath("FML", "ModList")); + + private enum Lineage { + MINERALOGY_110("Mineralogy 1.10", IGNEOUS_110, METAMORPHIC_110, + Collections.emptyList()), + MINERALOGY_112("Mineralogy 1.12", IGNEOUS_112, METAMORPHIC_112, SEDIMENTARY_112), + MINERALOGY_5("Mineralogy 5.x", IGNEOUS_5, METAMORPHIC_5, SEDIMENTARY_5); + + final String label; + final List igneous; + final List metamorphic; + final List sedimentary; + + Lineage(String label, List igneous, List metamorphic, + List sedimentary) { + this.label = label; + this.igneous = igneous; + this.metamorphic = metamorphic; + this.sedimentary = sedimentary; + } + + static Lineage forVersion(String version) { + List parts = versionParts(version); + if (!parts.isEmpty() && parts.get(0) == 5) return MINERALOGY_5; + if (parts.size() >= 2 && parts.get(0) == 3 && parts.get(1) <= 3) { + return MINERALOGY_110; + } + return MINERALOGY_112; + } + } + + private static final class ConfigValues { + final Map scalars = new LinkedHashMap<>(); + final Map> lists = new LinkedHashMap<>(); + String scalar(String key) { return scalars.get(normalizeKey(key)); } + List list(String key) { + List value = lists.get(normalizeKey(key)); + return value == null ? Collections.emptyList() : Collections.unmodifiableList(value); + } + void clear() { scalars.clear(); lists.clear(); } + } + + private static final class ModListPath { + final String compound; + final String list; + ModListPath(String compound, String list) { this.compound = compound; this.list = list; } + } + + private static final class MineralogyIdentity { + final String version; + final String sourceFile; + final boolean legacy; + MineralogyIdentity(String version, String sourceFile, boolean legacy) { + this.version = version; + this.sourceFile = sourceFile; + this.legacy = legacy; + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java index 8b1fc8aa..02277e43 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java @@ -49,10 +49,13 @@ static boolean apply(BiomeGenerationSettingsBuilder generation) { generation.getFeatures(GenerationStep.Decoration.UNDERGROUND_DECORATION); changed |= VanillaOreFeatureGate.wrapFeatureList(undergroundDecoration); + List> local = + generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); + changed |= addUnique(local, BiomeSurfaceFeature.placedFeature()); + List> top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); changed |= addUnique(top, FlatBedrockFeature.placedFeature()); - changed |= addUnique(top, BiomeSurfaceFeature.placedFeature()); return changed; } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java index 3782b27d..b44bfed6 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java @@ -140,7 +140,8 @@ private static boolean generateChunk(WorldGenLevel world, ChunkAccess chunk, Hol int centerZ = chunkPos.getMinBlockZ() + 8; int geome = -1; if (Level.OVERWORLD.equals(dimension)) { - geome = classifier(worldSeed).classifyColumn(biome.value(), centerX, centerZ, + Identifier biomeId = biome.unwrapKey().map(ResourceKey::identifier).orElse(null); + geome = classifier(worldSeed).classifyColumn(biome.value(), biomeId, centerX, centerZ, scratch.geomeValues(geomeConfig.geomeCount())); } @@ -489,7 +490,7 @@ private static Set resolveBiomes(JsonObject rule, String idsKey, String d if (rule.has(idsKey) && rule.get(idsKey).isJsonArray()) { for (JsonElement element : rule.getAsJsonArray(idsKey)) { Identifier id = resource(element.getAsString()); - Biome biome = id == null ? null : zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.get(id); + Biome biome = id == null ? null : BiomeRegistryAccess.get(id); if (biome != null) result.add(biome); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java index 8952e092..a9a70f94 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -92,7 +92,9 @@ public boolean place(FeaturePlaceContext context) { net.minecraft.resources.ResourceKey dimension = world.getLevel().dimension(); BakedTerrainDimension terrain = GeomeConfig.terrainDimension(dimension); BakedGeomeConfig config = GeomeConfig.baked(dimension); - if (!OreSpawnConfig.placeOreSpawnRock() || terrain == null || config == null) { + WorldGeologyProfile profile = WorldGeologyProfileManager.activeProfile(); + if (!OreSpawnConfig.placeOreSpawnRock() || terrain == null || config == null + || (profile.hasLegacyMineralogySnapshot() && !profile.cyanoEnabled())) { return false; } @@ -171,8 +173,7 @@ private CachedGeology geology(net.minecraft.resources.ResourceKey dimensi if (current == null || current.seed != seed || current.mode != mode) { WorldGeologyProfile profile = WorldGeologyProfileManager.activeProfile(); current = mode == GeologyMode.LEGACY - ? new CachedGeology(seed, mode, new Geology(seed, profile.cyanoGeomeSize(), - profile.cyanoRockLayerNoise(), profile.cyanoLayerThickness(), config), null) + ? new CachedGeology(seed, mode, new Geology(seed, profile, config), null) : new CachedGeology(seed, mode, null, new GeomeGeology(seed, config)); geologyByDimension.put(dimension, current); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java index 6a7f171a..9e687370 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -1,5 +1,8 @@ package zone.moddev.mc.orespawn.worldgen; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Locale; import java.util.Optional; @@ -275,6 +278,34 @@ public int cyanoLayerThickness() { return nestedInt("cyano", "rock_layer_thickness", 8, 1, 255); } + public boolean cyanoEnabled() { + return nestedBoolean("cyano", "enabled", true); + } + + public boolean cyanoRealisticCoalLayers() { + return nestedBoolean("cyano", "realistic_coal_layers", false); + } + + public boolean hasLegacyMineralogySnapshot() { + return root.has("cyano") && root.get("cyano").isJsonObject() + && root.getAsJsonObject("cyano").has("migrated_from"); + } + + boolean hasCyanoRockOrder(String key) { + return root.has("cyano") && root.get("cyano").isJsonObject() + && root.getAsJsonObject("cyano").has(key) + && root.getAsJsonObject("cyano").get(key).isJsonArray(); + } + + List cyanoRockOrder(String key) { + if (!hasCyanoRockOrder(key)) return Collections.emptyList(); + List result = new ArrayList<>(); + for (JsonElement element : root.getAsJsonObject("cyano").getAsJsonArray(key)) { + if (element.isJsonPrimitive()) result.add(element.getAsString()); + } + return Collections.unmodifiableList(result); + } + private static JsonObject recommendedFormationJson() { JsonObject formations = new JsonObject(); formations.addProperty("algorithm", Algorithm.STABLE_LAYERS.configName()); @@ -289,9 +320,9 @@ private static JsonObject recommendedFormationJson() { custom.addProperty("vertical_thickness", 8); custom.addProperty("waviness_wavelength", 256.0D); custom.addProperty("waviness_amplitude", 48.0D); - custom.addProperty("edge_wavelength", 64.0D); - custom.addProperty("edge_amplitude", 12.0D); - custom.addProperty("edge_octaves", 2); + custom.addProperty("edge_wavelength", 96.0D); + custom.addProperty("edge_amplitude", 24.0D); + custom.addProperty("edge_octaves", 3); custom.addProperty("continuity", 0.85D); formations.add("custom", custom); return formations; 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 30107ba4..d1ad624e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -28,6 +28,7 @@ import net.neoforged.neoforge.event.server.ServerAboutToStartEvent; import net.neoforged.neoforge.event.server.ServerStoppedEvent; import net.neoforged.neoforge.event.level.LevelEvent; +import net.neoforged.fml.loading.FMLPaths; import net.minecraft.server.level.ServerLevel; import org.apache.logging.log4j.LogManager; @@ -138,15 +139,22 @@ public static void onServerAboutToStart(ServerAboutToStartEvent event) { LOGGER.info("Merged new OreSpawn worldgen-provider definitions into '{}'", profilePath); } } else { - pending = consumePendingProfile(); boolean generatedWorld = hasGeneratedOverworldChunks(worldRoot); + pending = consumePendingProfile(); String source; - if (pending != null) { + if (generatedWorld) { + WorldGeologyProfile legacyMineralogy = LegacyMineralogyProfileMigration.migrateIfNeeded( + worldRoot, FMLPaths.CONFIGDIR.get(), GeomeConfig.globalBaseProfile()); + if (legacyMineralogy != null) { + profile = legacyMineralogy; + source = "legacy Mineralogy settings (existing world)"; + } else { + profile = GeomeConfig.globalBaseProfile(); + source = "instance (existing world)"; + } + } else if (pending != null) { profile = pending; source = "Create World"; - } else if (generatedWorld) { - profile = GeomeConfig.globalBaseProfile(); - source = "instance (existing world)"; } else { profile = fallback.copy(); source = "installed-pack fresh-world"; diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java new file mode 100644 index 00000000..14dc4115 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -0,0 +1,33 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.minecraft.core.Holder; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.neoforged.neoforge.common.world.BiomeGenerationSettingsBuilder; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; + +import org.junit.jupiter.api.Test; + +class BiomeSurfaceFeatureOrderTest { + @Test + void surfacesRunBeforeStructuresAndVegetationWhileFlatBedrockStaysLast() { + BiomeSurfaceFeature.registerConfiguredFeature(); + FlatBedrockFeature.registerConfiguredFeature(); + BiomeGenerationSettingsBuilder generation = + new BiomeGenerationSettingsBuilder(BiomeGenerationSettings.EMPTY); + + assertTrue(OreSpawnBiomeModifier.apply(generation)); + + Holder surfaces = BiomeSurfaceFeature.placedFeature(); + Holder bedrock = FlatBedrockFeature.placedFeature(); + var local = generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); + var top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); + assertTrue(local.stream().anyMatch(feature -> feature.value() == surfaces.value())); + assertFalse(local.stream().anyMatch(feature -> feature.value() == bedrock.value())); + assertTrue(top.stream().anyMatch(feature -> feature.value() == bedrock.value())); + assertFalse(top.stream().anyMatch(feature -> feature.value() == surfaces.value())); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/FormationSettingsTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/FormationSettingsTest.java new file mode 100644 index 00000000..c1f3c51d --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/FormationSettingsTest.java @@ -0,0 +1,36 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; +import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset; + +class FormationSettingsTest { + @Test + void stableLayerEdgeDetailUsesTheRecalibratedPresetLadder() { + assertEdgeDetail(Preset.TINY, 48.0D, 4.0D, 1); + assertEdgeDetail(Preset.SMALL, 64.0D, 12.0D, 2); + assertEdgeDetail(Preset.AVERAGE, 96.0D, 24.0D, 3); + assertEdgeDetail(Preset.LARGE, 128.0D, 48.0D, 4); + assertEdgeDetail(Preset.HUGE, 192.0D, 96.0D, 5); + } + + @Test + void customAndRecommendedEdgeDefaultsMatchAverage() { + assertEdgeDetail(Preset.CUSTOM, 96.0D, 24.0D, 3); + + JsonObject custom = WorldGeologyProfile.recommended(false).toFormationJson() + .getAsJsonObject("custom"); + assertEquals(96.0D, custom.get("edge_wavelength").getAsDouble()); + assertEquals(24.0D, custom.get("edge_amplitude").getAsDouble()); + assertEquals(3, custom.get("edge_octaves").getAsInt()); + } + + private static void assertEdgeDetail(Preset preset, double wavelength, double amplitude, + int octaves) { + assertEquals(wavelength, preset.stableEdgeWavelength); + assertEquals(amplitude, preset.stableEdgeAmplitude); + assertEquals(octaves, preset.stableEdgeOctaves); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java new file mode 100644 index 00000000..05c283fe --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java @@ -0,0 +1,124 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Blocks; + +import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig.GeomeDefinition; +import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig.RockEntry; + +class GeomeTransitionTest { + private static final Identifier WINDSWEPT_HILLS = Identifier.parse("minecraft:windswept_hills"); + + @Test + void configuredBiomeWeightsWorkWithoutAForgeBiomeRegistryEntry() { + Map indexes = new LinkedHashMap<>(); + indexes.put("orespawn:first", 0); + indexes.put("orespawn:mountain_belt", 1); + Map weights = GeomeConfig.bakeBiomeIdentifierWeights(indexes, + Map.of(WINDSWEPT_HILLS.toString(), new double[] { 1.0D, 4.0D })); + BakedGeomeConfig config = config(weights); + + assertEquals(1, config.pickGeome(null, WINDSWEPT_HILLS, new double[2], 0.0D)); + } + + @Test + void savedWorldBoundaryUsesItsConfiguredBiomeInsteadOfEqualFallbackWeights() { + BakedGeomeConfig config = observedWorldConfig(); + GeomeGeology geology = new GeomeGeology(-4965128775892001975L, config); + double[] leftScores = new double[config.geomeCount()]; + double[] rightScores = new double[config.geomeCount()]; + + assertEquals(1, geology.classifyColumn(null, WINDSWEPT_HILLS, 225, -261, leftScores)); + assertEquals(1, geology.classifyColumn(null, WINDSWEPT_HILLS, 226, -261, rightScores)); + } + + @Test + void closeGeomeContestDoesNotMoveEveryStableLayerAtOneColumnBoundary() { + // These are the leading scores measured in New World 5 at z=-261. The + // fallback configuration changed winner between x=225 and x=226. + double[] leftScores = { 2.618658D, 2.618126D }; + double[] rightScores = { 2.619946D, 2.620774D }; + int changedLayers = 0; + for (int layer = -8; layer < 8; layer++) { + int left = GeomeGeology.pickStableLayerGeome(leftScores, 0, 1, layer, 37); + int right = GeomeGeology.pickStableLayerGeome(rightScores, 0, 1, layer, 37); + if (left != right) { + changedLayers++; + } + } + + assertTrue(changedLayers < 16, + "all stable layers changed geome together across the observed x=225/226 boundary"); + } + + @Test + void transitionBandUsesBothGeomesButKeepsClearDominanceOutsideIt() { + boolean sawFirst = false; + boolean sawSecond = false; + for (int layer = 0; layer < 16; layer++) { + int selected = GeomeGeology.pickStableLayerGeome(new double[] { 2.0D, 2.0D }, + 0, 1, layer, 91); + sawFirst |= selected == 0; + sawSecond |= selected == 1; + } + + assertTrue(sawFirst && sawSecond, "a tied geome boundary should be staggered by stable layer"); + assertEquals(0, GeomeGeology.pickStableLayerGeome(new double[] { 2.2D, 2.0D }, 0, 1, 3, 91)); + assertEquals(1, GeomeGeology.pickStableLayerGeome(new double[] { 2.0D, 2.2D }, 0, 1, 3, 91)); + } + + private static BakedGeomeConfig config(Map biomeWeightsById) { + double[] familyWeights = { 1.0D, 1.0D, 1.0D, 1.0D }; + GeomeDefinition[] geomes = { + new GeomeDefinition("orespawn:first", 1.0D, familyWeights.clone()), + new GeomeDefinition("orespawn:second", 1.0D, familyWeights.clone()) + }; + RockEntry[] rocks = { + new RockEntry(Blocks.STONE.defaultBlockState(), RockFamily.SEDIMENTARY, + 64, 64, -64, 319, 1.0D, true, new double[] { 1.0D, 1.0D }) + }; + FormationSettings formations = new FormationSettings(FormationSettings.Algorithm.STABLE_LAYERS, + 256.0D, 100.0D, 8, 48.0D, 64.0D, 12.0D, 2, 0.85D); + return new BakedGeomeConfig(geomes, 384.0D, 1.15D, 0.9D, 0.45D, + Collections.emptyMap(), biomeWeightsById, rocks, formations); + } + + private static BakedGeomeConfig observedWorldConfig() { + String[] names = { + "stable_craton", "mountain_belt", "volcanic_arc", "sedimentary_basin", + "coastal_shelf", "arid_basin", "wetland_basin", "glacial_highland" + }; + double[] bases = { 1.0D, 1.0D, 0.9D, 1.0D, 0.9D, 0.9D, 0.8D, 0.8D }; + GeomeDefinition[] geomes = new GeomeDefinition[names.length]; + Map indexes = new LinkedHashMap<>(); + for (int i = 0; i < names.length; i++) { + String id = "orespawn:" + names[i]; + indexes.put(id, i); + geomes[i] = new GeomeDefinition(id, bases[i], new double[] { 1.0D, 1.0D, 1.0D, 1.0D }); + } + double[] rule = new double[names.length]; + rule[0] = 1.0D; + rule[1] = 4.0D; + Map biomeWeights = GeomeConfig.bakeBiomeIdentifierWeights(indexes, + Map.of(WINDSWEPT_HILLS.toString(), rule)); + double[] rockWeights = new double[names.length]; + java.util.Arrays.fill(rockWeights, 1.0D); + RockEntry[] rocks = { + new RockEntry(Blocks.STONE.defaultBlockState(), RockFamily.SEDIMENTARY, + 64, 64, -64, 319, 1.0D, true, rockWeights) + }; + FormationSettings formations = new FormationSettings(FormationSettings.Algorithm.STABLE_LAYERS, + 256.0D, 100.0D, 8, 48.0D, 64.0D, 12.0D, 2, 0.85D); + return new BakedGeomeConfig(geomes, 384.0D, 1.15D, 0.9D, 0.45D, + Collections.emptyMap(), biomeWeights, rocks, formations); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java index 4933a140..20d78920 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigratorTest.java @@ -57,6 +57,9 @@ void migratesBaseMetalsOs3FixtureExactly() throws IOException { assertTrue(ore(ores, "starsteel_ore").getAsJsonObject("dimensions").has("minecraft:the_end")); assertEquals(127, rule(ore(ores, "coldiron_ore")).get("max_y").getAsInt()); assertEquals(0.125D, rule(ore(ores, "platinum_ore")).get("frequency").getAsDouble()); + String upgradeReport = read(temporary.resolve("orespawn-upgrade-report.txt")); + assertTrue(upgradeReport.contains("Spawn definitions imported: 11")); + assertTrue(upgradeReport.contains("Original legacy configuration files were retained unchanged")); } @Test @@ -213,4 +216,8 @@ private static JsonObject rule(JsonObject ore) { return ore.getAsJsonObject("dimensions").entrySet().iterator().next() .getValue().getAsJsonObject(); } + + private static String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java new file mode 100644 index 00000000..6f5c3680 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -0,0 +1,179 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.core.registries.BuiltInRegistries; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class LegacyMineralogyGeologyParityTest { + private static final String SEALED_VECTOR_SHA256 = + "FE97624A94338C9B7E6EE6EE018A48C13920C7E55012BDB5B05A8584DFA93F5C"; + + @BeforeAll + static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + void cyanoSamplerMatchesPublishedMineralogy540AndSealedVectors() throws Exception { + Block[] igneous = { Blocks.STONE, Blocks.OBSIDIAN, Blocks.NETHERRACK }; + Block[] metamorphic = { Blocks.COBBLESTONE, Blocks.MOSSY_COBBLESTONE }; + Block[] sedimentary = { Blocks.SANDSTONE, Blocks.GRAVEL, Blocks.COAL_ORE, + Blocks.SANDSTONE }; + MessageDigest sealed = MessageDigest.getInstance("SHA-256"); + + String configuredPath = System.getProperty("orespawn.mineralogy5Oracle", ""); + Path oracle = configuredPath.trim().isEmpty() ? null : Paths.get(configuredPath); + PublishedMineralogy published = oracle != null && Files.isRegularFile(oracle) + ? PublishedMineralogy.open(oracle) : null; + try { + if (published != null) published.configure(9, igneous, metamorphic, sedimentary); + for (long seed : new long[] { 0L, -4965128775892001975L }) { + Geology os4 = new Geology(seed, 128.0D, 37.25D, 9, false, + states(igneous), states(metamorphic), states(sedimentary)); + PublishedSampler sampler = published == null ? null : published.newSampler(seed, 128.0D, 37.25D); + for (int x : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { + for (int z : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { + for (int y = 0; y < 256; y += 7) { + Block actual = os4.getStoneAt(x, y, z); + update(sealed, seed, x, y, z, actual); + if (sampler != null) { + assertEquals(sampler.getStoneAt(x, y, z), actual, + "Published Mineralogy 5.4.0 mismatch at " + + seed + ":" + x + ":" + y + ":" + z); + } + } + } + } + } + } finally { + if (published != null) published.close(); + } + + assertEquals(SEALED_VECTOR_SHA256, hex(sealed.digest()), + "The sealed vector digest is generated from the exact published Mineralogy 5.4.0 sampler"); + if (oracle != null) { + assertTrue(Files.isRegularFile(oracle), "Configured Mineralogy oracle is missing: " + oracle); + } + } + + private static void update(MessageDigest digest, long seed, int x, int y, int z, Block block) { + String id = BuiltInRegistries.BLOCK.getKey(block).toString(); + digest.update((seed + ":" + x + ":" + y + ":" + z + "=" + id + "\n") + .getBytes(StandardCharsets.UTF_8)); + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte value : bytes) result.append(String.format("%02X", value)); + return result.toString(); + } + + private static BlockState[] states(Block[] blocks) { + BlockState[] states = new BlockState[blocks.length]; + for (int i = 0; i < blocks.length; i++) states[i] = blocks[i].defaultBlockState(); + return states; + } + + private static final class PublishedMineralogy implements AutoCloseable { + private final URLClassLoader loader; + private final Class geologyClass; + private final List igneous; + private final List metamorphic; + private final List sedimentary; + private final Field thickness; + private final List originalIgneous; + private final List originalMetamorphic; + private final List originalSedimentary; + private final int originalThickness; + + @SuppressWarnings("unchecked") + private PublishedMineralogy(URLClassLoader loader) throws Exception { + this.loader = loader; + geologyClass = Class.forName("com.mcmoddev.mineralogy.worldgen.Geology", true, loader); + Class registry = Class.forName("com.mcmoddev.mineralogy.init.MineralogyRegistry", true, loader); + igneous = (List) registry.getField("igneousStones").get(null); + metamorphic = (List) registry.getField("metamorphicStones").get(null); + sedimentary = (List) registry.getField("sedimentaryStones").get(null); + originalIgneous = new ArrayList<>(igneous); + originalMetamorphic = new ArrayList<>(metamorphic); + originalSedimentary = new ArrayList<>(sedimentary); + Class config = Class.forName("com.mcmoddev.mineralogy.MineralogyConfig", true, loader); + thickness = config.getDeclaredField("geomLayerThickness"); + thickness.setAccessible(true); + originalThickness = thickness.getInt(null); + } + + static PublishedMineralogy open(Path jar) throws Exception { + URLClassLoader loader = new URLClassLoader(new URL[] { jar.toUri().toURL() }, + LegacyMineralogyGeologyParityTest.class.getClassLoader()); + try { return new PublishedMineralogy(loader); } + catch (Throwable failure) { loader.close(); throw failure; } + } + + void configure(int layerThickness, Block[] igneousValues, + Block[] metamorphicValues, Block[] sedimentaryValues) throws Exception { + reset(igneous, igneousValues); + reset(metamorphic, metamorphicValues); + reset(sedimentary, sedimentaryValues); + thickness.setInt(null, layerThickness); + } + + PublishedSampler newSampler(long seed, double geomeSize, double layerNoise) + throws Exception { + Constructor constructor = geologyClass.getConstructor( + long.class, double.class, double.class); + Object delegate = constructor.newInstance(seed, geomeSize, layerNoise); + return new PublishedSampler(delegate, + geologyClass.getMethod("getStoneAt", int.class, int.class, int.class)); + } + + @Override + public void close() throws Exception { + reset(igneous, originalIgneous.toArray(new Block[0])); + reset(metamorphic, originalMetamorphic.toArray(new Block[0])); + reset(sedimentary, originalSedimentary.toArray(new Block[0])); + thickness.setInt(null, originalThickness); + loader.close(); + } + + private static void reset(List target, Block[] values) { + target.clear(); + for (Block value : values) target.add(value); + } + } + + private static final class PublishedSampler { + private final Object delegate; + private final Method getStoneAt; + private PublishedSampler(Object delegate, Method getStoneAt) { + this.delegate = delegate; + this.getStoneAt = getStoneAt; + } + Block getStoneAt(int x, int y, int z) throws Exception { + return (Block) getStoneAt.invoke(delegate, x, y, z); + } + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java new file mode 100644 index 00000000..9379b921 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigrationTest.java @@ -0,0 +1,356 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; + +class LegacyMineralogyProfileMigrationTest { + @BeforeAll + static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + void carriedMineralogy110ConfigRetainsItsExactCyanoContract(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "FML", "ModList", "3.3.8.26"); + Path config = config(root, "mineralogy.cfg", + "I:GEOME_SIZE=144\n" + + "B:REALISTIC_COAL_LAYERS=true\n" + + "S:ROCK_LAYER_NOISE=41.5\n" + + "I:ROCK_LAYER_THICKNESS=11\n" + + "S:igneous_whitelist=minecraft:obsidian;mineralogy:diabase\n" + + "S:igneous_blacklist=mineralogy:gabbro\n" + + "S:metamorphic_whitelist=minecraft:cobblestone\n" + + "S:metamorphic_blacklist=mineralogy:slate\n" + + "S:sedimentary_whitelist=minecraft:gravel\n" + + "S:sedimentary_blacklist=mineralogy:gypsum\n"); + String sourceHash = sha256(config.resolve("mineralogy.cfg")); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(GeologyMode.LEGACY, migrated.geologyMode()); + assertTrue(migrated.cyanoEnabled()); + assertTrue(migrated.cyanoRealisticCoalLayers()); + assertEquals(144, migrated.cyanoGeomeSize()); + assertEquals(41.5D, migrated.cyanoRockLayerNoise()); + assertEquals(11, migrated.cyanoLayerThickness()); + JsonObject cyano = migrated.toJson().getAsJsonObject("cyano"); + assertEquals("Mineralogy 1.10", string(cyano, "legacy_lineage")); + assertEquals(Arrays.asList("minecraft:obsidian", "mineralogy:diabase"), + strings(cyano, "igneous_whitelist")); + assertFalse(strings(cyano, "igneous_rocks").contains("mineralogy:gabbro")); + assertTrue(strings(cyano, "sedimentary_rocks").contains("minecraft:coal_ore")); + assertEquals(sourceHash, sha256(config.resolve("mineralogy.cfg"))); + assertReport(world, "Selected config lineage: Mineralogy 1.10", + "Selected engine: LEGACY", "Cyano layer engine"); + } + + @Test + void nativeMineralogy112RetainsDuplicateRockSaltAndDisabledState(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "FML", "ModList", "3.8.0.53"); + Path config = config(root, "mineralogy.cfg", + "B:PLACE_MINERALOGY_ROCK=false\n" + + "I:GEOME_SIZE=128\n" + + "S:ROCK_LAYER_NOISE=37.25\n" + + "I:ROCK_LAYER_THICKNESS=9\n"); + + WorldGeologyProfile migrated = migrate(world, config); + JsonObject cyano = migrated.toJson().getAsJsonObject("cyano"); + + assertEquals(GeologyMode.LEGACY, migrated.geologyMode()); + assertFalse(migrated.cyanoEnabled()); + assertEquals("Mineralogy 1.12", string(cyano, "legacy_lineage")); + assertEquals(2, count(strings(cyano, "sedimentary_rocks"), "mineralogy:rock_salt")); + assertReport(world, "Legacy Mineralogy geology was disabled and remains disabled", + "Source file found: yes"); + } + + @Test + void publishedMineralogy540TomlPreservesEngineNumbersAndAllLists(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.4.0"); + Path config = config(root, "mineralogy-common.toml", + "[options]\n" + + "PLACE_MINERALOGY_ROCK = true\n" + + "[world-gen]\n" + + "GEOLOGY_MODE = \"GEOME\"\n" + + "GEOME_SIZE = 196\n" + + "ROCK_LAYER_NOISE = 44.75\n" + + "ROCK_LAYER_THICKNESS = 13\n" + + "igneous_whitelist = [\"minecraft:obsidian\", \"minecraft:obsidian\", \"minecraft:netherrack\"]\n" + + "igneous_blacklist = [\"mineralogy:basalt\"]\n" + + "metamorphic_whitelist = [\"minecraft:cobblestone\"]\n" + + "metamorphic_blacklist = [\"mineralogy:slate\"]\n" + + "sedimentary_whitelist = [\n" + + " \"minecraft:gravel\", # retained comment\n" + + " \"minecraft:sand\"\n" + + "]\n" + + "sedimentary_blacklist = [\"mineralogy:gypsum\"]\n"); + + WorldGeologyProfile migrated = migrate(world, config); + JsonObject cyano = migrated.toJson().getAsJsonObject("cyano"); + + assertEquals(GeologyMode.GEOME, migrated.geologyMode()); + assertTrue(migrated.cyanoEnabled()); + assertEquals(196, migrated.cyanoGeomeSize()); + assertEquals(44.75D, migrated.cyanoRockLayerNoise()); + assertEquals(13, migrated.cyanoLayerThickness()); + assertEquals("Mineralogy 5.x", string(cyano, "legacy_lineage")); + assertEquals(Arrays.asList("minecraft:obsidian", "minecraft:obsidian", "minecraft:netherrack"), + strings(cyano, "igneous_whitelist")); + assertEquals(1, count(strings(cyano, "igneous_rocks"), "minecraft:obsidian"), + "Mineralogy 5 deduplicated whitelist additions before sampling"); + assertFalse(strings(cyano, "igneous_rocks").contains("mineralogy:basalt")); + assertEquals(Arrays.asList("minecraft:gravel", "minecraft:sand"), + strings(cyano, "sedimentary_whitelist")); + assertReport(world, "Saved mod metadata: level.dat (fml/LoadingModList)", + "Selected engine: GEOME", "Mineralogy geome engine"); + } + + @Test + void mineralogy5LegacyEngineChoiceRemainsCyano(@TempDir Path root) throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.4.0"); + Path config = config(root, "mineralogy-common.toml", + "[options]\nPLACE_MINERALOGY_ROCK = true\n" + + "[world-gen]\nGEOLOGY_MODE = \"LEGACY\"\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(GeologyMode.LEGACY, migrated.geologyMode()); + assertReport(world, "Selected engine: LEGACY", "Cyano layer engine"); + } + + @Test + void malformedMineralogy5ValuesUsePublishedDefaultsWithoutBroadening(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE = \"unknown\"\nGEOME_SIZE = \"bad\"\n" + + "ROCK_LAYER_NOISE = -2\nROCK_LAYER_THICKNESS = 9999\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(GeologyMode.GEOME, migrated.geologyMode()); + assertEquals(100, migrated.cyanoGeomeSize()); + assertEquals(1.0D, migrated.cyanoRockLayerNoise()); + assertEquals(255, migrated.cyanoLayerThickness()); + assertReport(world, "Invalid GEOLOGY_MODE 'unknown'", "published GEOME default used"); + } + + @Test + void savedLineageWinsWhenAnotherStaleConfigFileIsPresent(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy.cfg", + "B:PLACE_MINERALOGY_ROCK=false\nI:GEOME_SIZE=144\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertTrue(migrated.cyanoEnabled()); + assertEquals(100, migrated.cyanoGeomeSize()); + assertReport(world, "Source file found: no; published Mineralogy 5.x defaults used", + "Found mineralogy.cfg but saved world metadata selects Mineralogy 5.x"); + } + + @Test + void freshWorldWithStaleLegacyFilesKeepsCurrentCreateWorldChoice(@TempDir Path root) + throws Exception { + Path fresh = root.resolve("fresh"); + Files.createDirectories(fresh); + writeLevelDat(fresh, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE=\"LEGACY\"\n"); + + assertNull(migrate(fresh, config)); + } + + @Test + void existingOs4WorldProfileWinsOverStaleLegacyMetadataAndConfig(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.4.0"); + Path serverConfig = Files.createDirectories(world.resolve("serverconfig")); + Path profile = serverConfig.resolve("orespawn-worldgen.json"); + byte[] original = WorldGeologyProfile.recommended(false).toJson().toString() + .getBytes(StandardCharsets.UTF_8); + Files.write(profile, original); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE=\"LEGACY\"\nGEOME_SIZE=177\n"); + + assertNull(migrate(world, config)); + assertTrue(Arrays.equals(original, Files.readAllBytes(profile))); + assertFalse(Files.exists(serverConfig.resolve("orespawn-upgrade-report.txt"))); + } + + @Test + void validLevelDatOldIsUsedWhenCurrentMetadataCannotBeRead(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Files.move(world.resolve("level.dat"), world.resolve("level.dat_old")); + Files.write(world.resolve("level.dat"), new byte[] { 1, 2, 3, 4 }); + Path config = config(root, "mineralogy-common.toml", "[world-gen]\nGEOME_SIZE=121\n"); + + WorldGeologyProfile migrated = migrate(world, config); + + assertEquals(121, migrated.cyanoGeomeSize()); + assertReport(world, "Saved mod metadata: level.dat_old (fml/LoadingModList)"); + } + + @Test + void modernMineralogyWorldIsNotReclassifiedByOldFiles(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "6.0.0"); + Path config = config(root, "mineralogy.cfg", "I:GEOME_SIZE=144\n"); + assertNull(migrate(world, config)); + } + + @Test + void migrationAndReportAreByteStableAndSourceRemainsUntouched(@TempDir Path root) + throws Exception { + Path world = existingWorld(root, "fml", "LoadingModList", "5.0.1"); + Path config = config(root, "mineralogy-common.toml", + "[world-gen]\nGEOLOGY_MODE=\"LEGACY\"\nGEOME_SIZE=111\n"); + Path source = config.resolve("mineralogy-common.toml"); + String sourceHash = sha256(source); + + WorldGeologyProfile first = migrate(world, config); + Path report = world.resolve("serverconfig/orespawn-upgrade-report.txt"); + byte[] reportBytes = Files.readAllBytes(report); + WorldGeologyProfile second = migrate(world, config); + + assertEquals(first.toJson(), second.toJson()); + assertTrue(Arrays.equals(reportBytes, Files.readAllBytes(report))); + assertEquals(sourceHash, sha256(source)); + } + + @Test + void snapshottedOrderPreservesDuplicatesAndFallsBackOnlyForEmptyFamily() { + WorldGeologyProfile base = WorldGeologyProfile.recommended(false); + JsonObject root = base.rootCopy(); + JsonObject cyano = new JsonObject(); + cyano.add("sedimentary_rocks", array( + "minecraft:sandstone", "minecraft:coal_ore", "minecraft:sandstone")); + cyano.addProperty("migrated_from", "test"); + root.add("cyano", cyano); + BlockState[] resolved = Geology.resolveRockOrder(base.withRoot(root), + "sedimentary_rocks", new BlockState[] { Blocks.BEDROCK.defaultBlockState() }); + assertEquals(3, resolved.length); + assertEquals(Blocks.SANDSTONE, resolved[0].getBlock()); + assertEquals(Blocks.COAL_ORE, resolved[1].getBlock()); + assertEquals(Blocks.SANDSTONE, resolved[2].getBlock()); + + JsonObject missingRoot = base.rootCopy(); + JsonObject missingCyano = new JsonObject(); + missingCyano.add("igneous_rocks", array("missingmod:removed_rock")); + missingCyano.addProperty("migrated_from", "test"); + missingRoot.add("cyano", missingCyano); + BlockState[] fallback = { Blocks.OBSIDIAN.defaultBlockState() }; + assertEquals(Blocks.OBSIDIAN, Geology.resolveRockOrder(base.withRoot(missingRoot), + "igneous_rocks", fallback)[0].getBlock()); + } + + private static WorldGeologyProfile migrate(Path world, Path config) { + return LegacyMineralogyProfileMigration.migrateIfNeeded( + world, config, WorldGeologyProfile.recommended(false)); + } + + private static Path existingWorld(Path root, String compound, String list, + String version) throws IOException { + Path world = root.resolve("world"); + Files.createDirectories(world.resolve("region")); + Files.write(world.resolve("region/r.0.0.mca"), new byte[] { 0 }); + writeLevelDat(world, compound, list, version); + return world; + } + + private static Path config(Path root, String name, String contents) throws IOException { + Path config = root.resolve("config"); + Files.createDirectories(config); + Files.write(config.resolve(name), contents.getBytes(StandardCharsets.UTF_8)); + return config; + } + + private static void writeLevelDat(Path world, String compound, String list, + String version) throws IOException { + CompoundTag root = new CompoundTag(); + CompoundTag fml = new CompoundTag(); + ListTag mods = new ListTag(); + CompoundTag mod = new CompoundTag(); + mod.putString("ModId", "mineralogy"); + mod.putString("ModVersion", version); + mods.add(mod); + fml.put(list, mods); + root.put(compound, fml); + try (FileOutputStream output = new FileOutputStream(world.resolve("level.dat").toFile())) { + NbtIo.writeCompressed(root, output); + } + } + + private static void assertReport(Path world, String... fragments) throws IOException { + String report = new String(Files.readAllBytes( + world.resolve("serverconfig/orespawn-upgrade-report.txt")), StandardCharsets.UTF_8); + for (String fragment : fragments) assertTrue(report.contains(fragment), fragment); + } + + private static String string(JsonObject parent, String key) { + return parent.get(key).getAsString(); + } + + private static List strings(JsonObject parent, String key) { + List result = new ArrayList<>(); + for (JsonElement value : parent.getAsJsonArray(key)) result.add(value.getAsString()); + return result; + } + + private static int count(List values, String expected) { + int count = 0; + for (String value : values) if (expected.equals(value)) count++; + return count; + } + + private static JsonArray array(String... values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String sha256(Path path) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] result = digest.digest(Files.readAllBytes(path)); + StringBuilder hex = new StringBuilder(); + for (byte value : result) hex.append(String.format("%02X", value)); + return hex.toString(); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java index 1d315f70..f87bb67b 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java @@ -342,6 +342,55 @@ void worldProfileRefreshPreservesOriginalAndCustomRules(@TempDir Path temporaryD persisted.get("ore_defaults_revision").getAsInt()); } + @Test + void os404GlobalOnlyProfilePreservesCustomValuesAndProviderDefinitions() { + JsonObject global = completeGlobalFixture(); + global.getAsJsonObject("formations").addProperty("edge_irregularity", "custom"); + global.getAsJsonObject("formations").getAsJsonObject("custom") + .addProperty("edge_amplitude", 37.25D); + JsonObject provider = new JsonObject(); + provider.addProperty("provider_revision", 44); + global.getAsJsonObject("providers").add("example:rocks", provider); + + JsonObject result = WorldGeologyProfile.fromGlobalConfig(global, + GeologyMode.GEOME, false).toJson(); + + assertEquals(37.25D, result.getAsJsonObject("formations") + .getAsJsonObject("custom").get("edge_amplitude").getAsDouble()); + assertEquals(44, result.getAsJsonObject("providers") + .getAsJsonObject("example:rocks").get("provider_revision").getAsInt()); + } + + @Test + void os404WorldProfileWinsOverGlobalAndReloadsByteStable(@TempDir Path temporaryDirectory) + throws IOException { + JsonObject global = completeGlobalFixture(); + global.getAsJsonObject("formations").getAsJsonObject("custom") + .addProperty("edge_amplitude", 91.0D); + JsonObject world = completeGlobalFixture(); + world.getAsJsonObject("formations").addProperty("edge_irregularity", "custom"); + world.getAsJsonObject("formations").getAsJsonObject("custom") + .addProperty("edge_amplitude", 23.5D); + JsonObject provider = new JsonObject(); + provider.addProperty("provider_revision", 17); + world.getAsJsonObject("providers").add("example:world_provider", provider); + Path profilePath = temporaryDirectory.resolve("orespawn-worldgen.json"); + Files.write(profilePath, world.toString().getBytes(StandardCharsets.UTF_8)); + WorldGeologyProfile fallback = WorldGeologyProfile.fromGlobalConfig(global, + GeologyMode.GEOME, true); + + WorldGeologyProfile first = WorldGeologyProfileManager.readProfile(profilePath, fallback); + byte[] persisted = Files.readAllBytes(profilePath); + WorldGeologyProfile second = WorldGeologyProfileManager.readProfile(profilePath, fallback); + + assertEquals(23.5D, first.toJson().getAsJsonObject("formations") + .getAsJsonObject("custom").get("edge_amplitude").getAsDouble()); + assertEquals(17, first.toJson().getAsJsonObject("providers") + .getAsJsonObject("example:world_provider").get("provider_revision").getAsInt()); + assertEquals(first.toJson(), second.toJson()); + assertTrue(Arrays.equals(persisted, Files.readAllBytes(profilePath))); + } + @Test void oreDefaultsUpgradeCanonicalPluralPatternNames() { JsonObject original = oreDefaultsFixture(12.0D); diff --git a/src/test/resources/log4j2-test.xml b/src/test/resources/log4j2-test.xml new file mode 100644 index 00000000..9f076c67 --- /dev/null +++ b/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + +