From 2185071a89d14d1a7f9216286183b5fd6180730e Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Mon, 12 Oct 2026 16:55:01 +0100 Subject: [PATCH 1/2] Bring OreSpawn 4.1 to NeoForge 26.3 --- .github/workflows/ci.yml | 6 +- CHANGELOG.txt | 13 + README.md | 18 +- build.gradle | 913 +------------ ci-fixtures/config/neoforge-26.3-fml.toml | 14 + docs/AGENTS.md | 17 - docs/API.md | 18 +- docs/BIOMES.md | 18 +- docs/CONFIGURATION.md | 52 +- docs/DEVELOPER_GUIDE.md | 19 +- docs/PLAYER_GUIDE.md | 18 +- docs/PROVIDERS.md | 14 +- docs/README.md | 6 +- docs/VERSIONS.md | 4 +- docs/examples/examplemod-orespawn.json | 2 +- docs/examples/orespawn-global.json | 4 +- docs/examples/orespawn-world.json | 4 +- docs/schemas/orespawn-global.schema.json | 2 +- docs/schemas/orespawn-provider.schema.json | 2 +- docs/schemas/orespawn-world.schema.json | 2 +- docs/schemas/profile-common.schema.json | 56 +- gradle.properties | 2 +- gradle/README.md | 57 + gradle/ide/eclipse.gradle | 218 +++ gradle/release/artifacts.gradle | 279 ++++ gradle/release/publishing.gradle | 85 ++ gradle/verification/biome-override.gradle | 100 ++ .../verification/client-and-benchmark.gradle | 143 ++ gradle/verification/contracts.gradle | 69 + gradle/verification/native-ores.gradle | 105 ++ gradle/verification/ore-sources.gradle | 161 +++ gradle/verification/support.gradle | 255 ++++ .../clientprobe/ClientProbeTestMod.java | 113 +- .../clientprobe/EditorTextRenderProbe.java | 262 ++++ .../clientprobe/MainMenuLayoutProbe.java | 145 ++ .../clientprobe/SharedOreClientProbe.java | 49 + .../mc/orespawn/NeoForgeConfigType.java | 18 + .../zone/moddev/mc/orespawn/OreSpawn.java | 1 + .../moddev/mc/orespawn/OreSpawnConfig.java | 3 +- .../mc/orespawn/api/OreGenerationContext.java | 46 + .../mc/orespawn/api/WorldgenProvider.java | 29 +- .../api/client/WorldSettingsExtension.java | 29 + .../WorldSettingsExtensionRegistry.java | 76 ++ .../client/WorldSettingsScreenFactory.java | 9 + .../orespawn/client/BiomeDirectoryModel.java | 385 ++++++ .../orespawn/client/BiomePaletteScreen.java | 126 +- .../client/BiomePaletteSettingsScreen.java | 161 +++ .../orespawn/client/BiomePlacementScreen.java | 31 +- .../client/BiomeReplacementRules.java | 153 +++ .../client/BiomeWorldMaterialsScreen.java | 399 ++++-- .../moddev/mc/orespawn/client/Button.java | 67 + .../moddev/mc/orespawn/client/CogButton.java | 27 + .../mc/orespawn/client/CompactScrollList.java | 314 +++++ .../orespawn/client/GeologyEditorSession.java | 1197 ++++++++++++++++- .../client/GeologyMaterialsScreen.java | 12 +- .../client/OreSourceGroupSettingsScreen.java | 380 ++++++ .../orespawn/client/OreSourceListScreen.java | 500 +++++++ .../client/OreSpawnModDirectoryModel.java | 124 ++ .../orespawn/client/OreSpawnModsScreen.java | 194 +++ .../mc/orespawn/client/OreSpawnScreen.java | 47 +- .../orespawn/client/OreSpawnScreenLayout.java | 43 +- .../client/OreSpawnWorldCreationTab.java | 53 +- .../client/OreSpawnWorldSettingsScreen.java | 48 +- .../mc/orespawn/client/TextFieldWidget.java | 23 + .../WorldSettingsExtensionNavigation.java | 43 + .../documentation/DocumentationExporter.java | 1 - .../WorldgenIntegrationManager.java | 132 +- .../moddev/mc/orespawn/util/FluidBlocks.java | 19 + .../moddev/mc/orespawn/util/JsonCopies.java | 36 + .../worldgen/BiomeWorldgenManager.java | 19 +- .../worldgen/FluidDepositFeature.java | 3 +- .../mc/orespawn/worldgen/GeomeConfig.java | 40 +- .../worldgen/LegacyConfigMigrator.java | 2 +- .../LegacyMineralogyProfileMigration.java | 2 +- .../worldgen/MmdOreConflictCatalog.java | 64 + .../orespawn/worldgen/OreMaterialGroups.java | 320 +++++ .../orespawn/worldgen/OreSourcePolicies.java | 805 +++++++++++ .../worldgen/OreSpawnBiomeModifier.java | 8 + .../worldgen/OreSpawnOreGeneration.java | 305 ++++- .../mc/orespawn/worldgen/StoneReplacer.java | 6 +- .../worldgen/VanillaOreFeatureGate.java | 44 +- .../worldgen/WorldGeologyProfile.java | 21 +- .../worldgen/WorldGeologyProfileManager.java | 125 +- .../resources/assets/orespawn/lang/de_au.json | 157 ++- .../resources/assets/orespawn/lang/de_de.json | 157 ++- .../resources/assets/orespawn/lang/en_ca.json | 157 ++- .../resources/assets/orespawn/lang/en_en.json | 157 ++- .../resources/assets/orespawn/lang/en_gb.json | 157 ++- .../resources/assets/orespawn/lang/en_pt.json | 157 ++- .../resources/assets/orespawn/lang/en_us.json | 157 ++- .../resources/assets/orespawn/lang/es_es.json | 157 ++- .../resources/assets/orespawn/lang/es_mx.json | 157 ++- .../resources/assets/orespawn/lang/fr_ca.json | 157 ++- .../resources/assets/orespawn/lang/fr_fr.json | 157 ++- .../resources/assets/orespawn/lang/ja_jp.json | 157 ++- .../resources/assets/orespawn/lang/ko_kr.json | 157 ++- .../resources/assets/orespawn/lang/pt_br.json | 157 ++- .../resources/assets/orespawn/lang/ru_ru.json | 157 ++- .../resources/assets/orespawn/lang/zh_cn.json | 157 ++- .../testmod/NativeOreProbeBridge.java | 67 + .../testmod/NativeOreProbeTestMod.java | 253 ++++ .../resources/META-INF/neoforge.mods.toml | 21 + .../resources/pack.mcmeta | 7 + .../FutureAdvantageTestMod.java | 67 + .../resources/META-INF/neoforge.mods.toml | 16 + .../data/c/tags/block/ores/sulfur.json | 4 + .../biome_modifier/independent_marker.json | 6 + .../worldgen/feature/independent_marker.json | 1 + .../placed_feature/independent_marker.json | 9 + .../resources/pack.mcmeta | 7 + .../testmod/FutureMineralsTestMod.java | 131 ++ .../resources/META-INF/neoforge.mods.toml | 16 + .../data/c/tags/block/ores/sulfur.json | 4 + .../resources/pack.mcmeta | 7 + .../mc/orespawn/LocalizationParityTest.java | 6 +- .../mc/orespawn/NeoForgeConfigTypeTest.java | 91 ++ .../orespawn/ReleaseWorkflowContractTest.java | 30 +- .../api/OreGenerationContextTest.java | 56 + .../mc/orespawn/api/WorldgenProviderTest.java | 9 +- .../WorldSettingsExtensionRegistryTest.java | 152 +++ .../client/BiomeDirectoryModelTest.java | 167 +++ .../client/BiomeReplacementRulesTest.java | 59 + .../client/CompactScrollListTest.java | 135 ++ .../client/EditorNavigationWiringTest.java | 34 + .../client/GeologyEditorSessionTest.java | 144 ++ .../client/OreSpawnModDirectoryModelTest.java | 112 ++ .../client/OreSpawnScreenLayoutTest.java | 60 + .../WorldgenIntegrationManagerTest.java | 4 + .../orespawn/test/MinecraftTestBootstrap.java | 14 + .../worldgen/BiomePaletteOrderingTest.java | 167 +++ .../worldgen/FluidDepositFeatureTest.java | 2 +- .../worldgen/MmdOreConflictCatalogTest.java | 54 + ...eoForgeBiomeModifierCompatibilityTest.java | 91 ++ .../worldgen/OreMaterialGroupsTest.java | 65 + .../worldgen/OreSourcePoliciesTest.java | 103 ++ .../worldgen/VanillaOreFeatureGateTest.java | 192 +++ .../testmod/WorldHashProbeTestMod.java | 234 ++++ .../resources/META-INF/neoforge.mods.toml | 9 + src/worldHashTest/resources/pack.mcmeta | 1 + 139 files changed, 13613 insertions(+), 1241 deletions(-) create mode 100644 ci-fixtures/config/neoforge-26.3-fml.toml delete mode 100644 docs/AGENTS.md create mode 100644 gradle/README.md create mode 100644 gradle/ide/eclipse.gradle create mode 100644 gradle/release/artifacts.gradle create mode 100644 gradle/release/publishing.gradle create mode 100644 gradle/verification/biome-override.gradle create mode 100644 gradle/verification/client-and-benchmark.gradle create mode 100644 gradle/verification/contracts.gradle create mode 100644 gradle/verification/native-ores.gradle create mode 100644 gradle/verification/ore-sources.gradle create mode 100644 gradle/verification/support.gradle create mode 100644 src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/EditorTextRenderProbe.java create mode 100644 src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/MainMenuLayoutProbe.java create mode 100644 src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/SharedOreClientProbe.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/NeoForgeConfigType.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/Button.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/CogButton.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/util/FluidBlocks.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/util/JsonCopies.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalog.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java create mode 100644 src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeBridge.java create mode 100644 src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeTestMod.java create mode 100644 src/nativeOreIntegrationTest/resources/META-INF/neoforge.mods.toml create mode 100644 src/nativeOreIntegrationTest/resources/pack.mcmeta create mode 100644 src/oreSourceAdvantageTest/java/zone/moddev/mc/orespawn/advantagefixture/FutureAdvantageTestMod.java create mode 100644 src/oreSourceAdvantageTest/resources/META-INF/neoforge.mods.toml create mode 100644 src/oreSourceAdvantageTest/resources/data/c/tags/block/ores/sulfur.json create mode 100644 src/oreSourceAdvantageTest/resources/data/electricadvantage/neoforge/biome_modifier/independent_marker.json create mode 100644 src/oreSourceAdvantageTest/resources/data/electricadvantage/worldgen/feature/independent_marker.json create mode 100644 src/oreSourceAdvantageTest/resources/data/electricadvantage/worldgen/placed_feature/independent_marker.json create mode 100644 src/oreSourceAdvantageTest/resources/pack.mcmeta create mode 100644 src/oreSourceMineralsTest/java/zone/moddev/mc/orespawn/testmod/FutureMineralsTestMod.java create mode 100644 src/oreSourceMineralsTest/resources/META-INF/neoforge.mods.toml create mode 100644 src/oreSourceMineralsTest/resources/data/c/tags/block/ores/sulfur.json create mode 100644 src/oreSourceMineralsTest/resources/pack.mcmeta create mode 100644 src/test/java/zone/moddev/mc/orespawn/NeoForgeConfigTypeTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/EditorNavigationWiringTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/test/MinecraftTestBootstrap.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalogTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/NeoForgeBiomeModifierCompatibilityTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroupsTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGateTest.java create mode 100644 src/worldHashTest/java/zone/moddev/mc/orespawn/testmod/WorldHashProbeTestMod.java create mode 100644 src/worldHashTest/resources/META-INF/neoforge.mods.toml create mode 100644 src/worldHashTest/resources/pack.mcmeta diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce91a87d..6d96d554 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,9 +80,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/OreSpawn-4.0.16.2603002.jar - build/libs/OreSpawn-4.0.16.2603002-sources.jar - build/libs/OreSpawn-4.0.16.2603002-javadoc.jar + build/libs/OreSpawn-4.1.0.2603002.jar + build/libs/OreSpawn-4.1.0.2603002-sources.jar + build/libs/OreSpawn-4.1.0.2603002-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt - name: Upload diagnostics on failure diff --git a/CHANGELOG.txt b/CHANGELOG.txt index b0fe6f32..ffcdaf7e 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,16 @@ +Version 4.1.0.2603002 + +* Restore biome and ore-source list text, status labels and scrollbar arrows on Minecraft 26.3. +* Fix compact-list selection and scrollbar dragging with Minecraft 26.3's mouse-button numbering. +* Move Help & Guide beside Recommended Defaults and keep world-creation controls clear of the footer. +* Support NeoForge 26.3's renamed config type and updated biome modifier without dropping older builds or changing the common config file. +* Apply managed vanilla ore rules to inline native features and release server-owned wrappers when a world closes. +* Reject invalid geome IDs without rewriting them, and strengthen config, checksum and Eclipse launch checks. +* Add provider extensions, a mod directory, Ore Sources and built-in MMD conflict defaults. +* Add the biome directory, exact new-terrain replacements and dimension material controls. +* Keep existing worlds' ore rules while starting new shared-tag MMD groups Balanced. +* Split the NeoGradle build into smaller scripts and keep tracked text in LF format. + Version 4.0.16.2603002 * Port the accepted NeoForge 26.2 implementation to Minecraft 26.3 and diff --git a/README.md b/README.md index 7ea320a2..4c5bf9c6 100644 --- a/README.md +++ b/README.md @@ -94,15 +94,16 @@ exported to `config/orespawn-guide/` without overwriting existing files. ## Building -This branch builds target-qualified version `4.0.16.2603002`: the OreSpawn 4.0.16 +This branch builds target-qualified version `4.1.0.2603002`: the OreSpawn 4.1 feature set for Minecraft 26.3 and NeoForge. -OreSpawn 4.0.16.2603002 uses NeoGradle 7.1.39 and Gradle 9.2.1. Use exact +OreSpawn 4.1.0.2603002 uses NeoGradle 7.1.39 and Gradle 9.2.1. Use exact Temurin 25.0.3+9 from the repository root: ```powershell -.\gradlew.bat clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums --no-daemon --max-workers=2 -.\gradlew.bat eclipse verifyEclipseProductionClasspath --no-daemon --max-workers=2 +.\gradlew.bat clean --no-daemon --max-workers=2 +.\gradlew.bat check build javadoc verifyReleaseArtifacts writeReleaseChecksums --no-daemon --max-workers=2 +.\gradlew.bat eclipse verifyEclipseProductionClasspath '-Declipse.application=org.eclipse.jdt.ls.core.id1' --no-daemon --max-workers=2 ``` `build` runs the standard `check` lifecycle. In addition to the JUnit suite, @@ -117,7 +118,14 @@ Import or refresh the project with Eclipse Buildship. NeoGradle supplies the Eclipse model and run configurations through the `eclipse` task; this branch does not use ForgeGradle's `genEclipseRuns` task. -The audited public outputs are `OreSpawn-4.0.16.2603002.jar`, its sources jar, +The quoted Eclipse marker in the terminal command asks NeoGradle to regenerate +its native launches; Buildship supplies it automatically during an import. +For the isolated two-provider GUI scenario, use `runOreSourcesClient.launch` +or `runTestOreSourcesClient.launch`. These use `run-ore-sources` and leave the +ordinary profile's configs and saves alone. See [the build guide](gradle/README.md) +for the automatic shared-ore and packaged native-ore gates. + +The audited public outputs are `OreSpawn-4.1.0.2603002.jar`, its sources jar, and its Javadoc jar. CI additionally proves a cache-cold NeoGradle bootstrap and a same-cache offline rerun. diff --git a/build.gradle b/build.gradle index 2d64a57e..44eb5ac7 100644 --- a/build.gradle +++ b/build.gradle @@ -158,6 +158,33 @@ runs { } } + // The UI probe has its own options and saves, separate from a normal client run. + register('client41Integration') { + runType 'client' + workingDirectory layout.buildDirectory.dir('client-integration-run') + systemProperty 'clientprobe.enabled', 'true' + argument '--width' + argument '854' + argument '--height' + argument '530' + modSource project.sourceSets.main + } + + // The manual shared-ore profile has its own mods, options and saves. + register('oreSourcesClient') { + runType 'client' + workingDirectory project.layout.projectDirectory.dir('run-ore-sources') + modSource project.sourceSets.main + } + register('oreSourcesClientIntegration') { + runType 'client' + workingDirectory layout.buildDirectory.dir('client-ore-sources-run') + systemProperty 'clientprobe.enabled', 'true' + systemProperty 'clientprobe.sharedOres', 'true' + arguments.addAll '--width', '854', '--height', '530' + modSource project.sourceSets.main + } + server { systemProperty 'neoforge.enabledGameTestNamespaces', mod_id argument '--nogui' @@ -193,6 +220,29 @@ runs { modSource project.sourceSets.main } } + ['One', 'Both'].each { String scenario -> + ['Fresh', 'Reload', 'Missing', 'Restored'].each { String phase -> + if (scenario == 'One' && phase != 'Fresh' && phase != 'Reload') return + register("oreSource${scenario}${phase}") { + runType 'server' + workingDirectory layout.buildDirectory.dir("ore-source-${scenario.toLowerCase(Locale.ROOT)}-run") + systemProperty 'forge.logging.console.level', 'info' + systemProperty 'oresourceprobe.phase', phase.toLowerCase(Locale.ROOT) + argument '--nogui' + modSource project.sourceSets.main + } + } + } + ['Fresh', 'Reload'].each { String phase -> + register("biomeOverride${phase}") { + runType 'server' + workingDirectory layout.buildDirectory.dir('biome-override-run') + systemProperty 'forge.logging.console.level', 'info' + systemProperty 'orespawn.biomeOverridePhase', phase.toLowerCase(Locale.ROOT) + argument '--nogui' + modSource project.sourceSets.main + } + } } configurations { @@ -260,10 +310,6 @@ tasks.withType(ProcessResources).configureEach { tasks.named('processResources', ProcessResources).configure { dependsOn prepareBundledDocumentation - from('docs/AGENTS.md') { - into '' - rename { 'AGENTS.md' } - } } tasks.named('jar', Jar).configure { @@ -338,846 +384,23 @@ tasks.named('compileTestJava', JavaCompile).configure { options.compilerArgs.add('-proc:none') } -tasks.register('verifyLegacyOracleIsolation') { - group = 'verification' - dependsOn tasks.named('verifyLegacyFixtures') - doLast { - configurations.findAll { it.canBeResolved }.each { configuration -> - if (configuration.files.any { it.canonicalFile == mineralogy5OracleJar.canonicalFile }) { - throw new GradleException("Mineralogy oracle leaked into ${configuration.name}") - } - } - } -} - -// 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. -// NeoForge 26.3 and its plain official MDK produce this exact OSHI diagnostic -// on the current Windows host before mod loading. Keep the allowance scoped to -// the emitting class and complete message; every other ERROR/FATAL remains fatal. -def acceptedNeoForge263LogNoise = [ - ~/.*\[main\/ERROR\] \[oshi\.driver\.windows\.registry\.HkeyPerformanceDataUtil\/\]: Unable to locate English counter names in registry Perflib 009\. Counters may need to be rebuilt:\s*/ +// Smaller scripts share only target values and the few helpers they need. +ext.oreSpawnBuild = [ + mineralogy5OracleJar: mineralogy5OracleJar, + expectedMavenGroup: expectedMavenGroup, + expectedMavenArtifact: expectedMavenArtifact, + expectedMavenCoordinate: expectedMavenCoordinate, + archiveTextSuffixes: archiveTextSuffixes, + bundledDocumentationDirectory: bundledDocumentationDirectory, + prepareBundledDocumentation: prepareBundledDocumentation ] -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 = acceptedNeoForge263LogNoise.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( - '[16Sept2026 08:00:15.959] [main/ERROR] [oshi.driver.windows.registry.HkeyPerformanceDataUtil/]: Unable to locate English counter names in registry Perflib 009. Counters may need to be rebuilt: \n', - 'UTF-8') - assertRuntimeLogsClean(probe, 'scanner-neoforge-263-windows-control-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() - if (taskName == 'runGameTestServer') { - File serverProperties = new File(runTask.workingDir, 'server.properties') - if (!serverProperties.isFile()) { - serverProperties.setText('# Generated for the OreSpawn GameTest gate\n', 'UTF-8') - } - } - runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) - } - doLast { - assertRuntimeLogsClean(runTask.workingDir, taskName, - runTask.ext.oreSpawnCrashSnapshot as Set) - } - } -} - -def surfaceIntegrationClasses = layout.buildDirectory.dir('surface-integration-fixture/classes') -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(surfaceIntegrationClasses) - javaCompiler.set(javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(25) - }) - options.release = 25 - options.encoding = 'UTF-8' -} - -def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { - dependsOn compileSurfaceIntegrationTestMod - archiveFileName = 'surfaceprobe.jar' - destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' -} - -def surfaceIntegrationRunDirectory = layout.buildDirectory.dir('surface-integration-run') -def prepareSurfaceIntegrationTest = tasks.register('prepareSurfaceIntegrationTest') { - dependsOn surfaceIntegrationTestModJar - doLast { - delete surfaceIntegrationRunDirectory - File runDirectory = surfaceIntegrationRunDirectory.get().asFile - runDirectory.mkdirs() - new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - new File(runDirectory, 'server.properties').setText( - 'level-seed=0\nonline-mode=false\nserver-port=0\nspawn-protection=0\nsync-chunk-writes=false\n', - 'UTF-8') - copy { - from surfaceIntegrationTestModJar.flatMap { it.archiveFile } - into surfaceIntegrationRunDirectory.map { it.dir('mods') } - } - } -} - -tasks.configureEach { - 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.3 ${name}", - ext.oreSpawnCrashSnapshot as Set) - } - } -} - -def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { - group = 'verification' - description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' - dependsOn 'runSurfaceIntegrationReload' - doLast { - File marker = surfaceIntegrationRunDirectory.get().file( - 'world/surfaceprobe-integration.properties').asFile - if (!marker.isFile()) { - 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("Surface integration reload was not verified: ${marker}") - } - 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 surfaceIntegrationTest -} - -def clientIntegrationClasses = layout.buildDirectory.dir('client-integration-fixture/classes') -def compileClientIntegrationTestMod = tasks.register('compileClientIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/clientIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory.set(clientIntegrationClasses) - options.release = 25 - options.encoding = 'UTF-8' -} -def clientIntegrationTestModJar = tasks.register('clientIntegrationTestModJar', Jar) { - dependsOn compileClientIntegrationTestMod - archiveFileName = 'clientprobe.jar' - destinationDirectory = layout.buildDirectory.dir('client-integration-fixture') - from clientIntegrationClasses - from 'src/clientIntegrationTest/resources' -} - -def releaseJar = tasks.named('jar', Jar) -def expectedReleaseFiles = providers.provider { - String prefix = "${base.archivesName.get()}-${project.version}" - ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] -} -def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') - -tasks.named('assemble') { - dependsOn releaseJar - dependsOn tasks.named('sourcesJar') - dependsOn tasks.named('javadocJar') -} - -tasks.register('verifyReleaseConfiguration') { - group = 'verification' - doLast { - if (project.mod_version != '4.0.16.2603002' - || project.mod_group_id != expectedMavenGroup - || project.minecraft_version != '26.3' - || project.neo_version != '26.3.0.1-beta') { - throw new GradleException('Unexpected OreSpawn NeoForge 26.3 release identity') - } - if (project.loader_name != 'neoforge' || project.loader_code != '2' - || project.java_version != '25' || project.gradle_java_version != '25' - || project.java_toolchain_version != '25.0.3+9' - || project.java_setup_version != '25.0.3+9.0.LTS') { - throw new GradleException('Unexpected dispatcher or Java target metadata') - } - List expectedPublicArtifacts = [ - 'OreSpawn-4.0.16.2603002.jar', - 'OreSpawn-4.0.16.2603002-sources.jar', - 'OreSpawn-4.0.16.2603002-javadoc.jar' - ] - if (base.archivesName.get() != expectedMavenArtifact - || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { - throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') - } - String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') - expectedPublicArtifacts.each { artifactName -> - if (!ciWorkflow.contains("build/libs/${artifactName}")) { - throw new GradleException("CI does not upload expected public artifact ${artifactName}") - } - } - [ - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', - 'README.md', 'CHANGELOG.txt' - ].each { path -> - if (!file(path).getText('UTF-8').contains(project.mod_version)) { - throw new GradleException("Release identity missing from ${path}") - } - } - if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { - throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') - } - } -} - -def trackedDocumentationDirectory = file('docs') -def documentationFiles = { - fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { - trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') }.sort() -} -def assertDocumentationTree = { File root, List expected, String label -> - List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } - .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') }.sort() : [] - if (actual != expected) { - throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") - } - expected.each { relative -> - if (!java.util.Arrays.equals(new File(trackedDocumentationDirectory, relative).bytes, - new File(root, relative).bytes)) { - throw new GradleException("${label}/${relative} differs from tracked documentation") - } - } -} - -def prepareEclipseResources = tasks.register('prepareEclipseResources', Sync) { - dependsOn tasks.named('processResources') - from layout.buildDirectory.dir('resources/main') - into file('bin/main') -} - -def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { - group = 'verification' - dependsOn prepareBundledDocumentation - dependsOn tasks.named('processResources') - dependsOn prepareEclipseResources - dependsOn releaseJar - doLast { - List expected = documentationFiles() - if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { - throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") - } - assertDocumentationTree(bundledDocumentationDirectory.asFile, expected, 'generated resources') - assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, - 'META-INF/orespawn/docs'), expected, 'processed resources') - assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), expected, 'Eclipse bin/main') - new ZipFile(releaseJar.get().archiveFile.get().asFile).withCloseable { zip -> - expected.each { relative -> - def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") - if (entry == null || !java.util.Arrays.equals( - new File(trackedDocumentationDirectory, relative).bytes, - zip.getInputStream(entry).withCloseable { it.bytes })) { - throw new GradleException("Release jar documentation differs at ${relative}") - } - } - } - } -} - -tasks.register('verifyReleaseArtifacts') { - group = 'verification' - dependsOn tasks.named('verifyReleaseConfiguration') - dependsOn verifyDocumentationParity - dependsOn tasks.named('assemble') - dependsOn tasks.named('verifyLegacyOracleIsolation') - doLast { - File libs = layout.buildDirectory.dir('libs').get().asFile - List jars = (libs.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') }.sort { it.name } - List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() - if (jars.collect { it.name } != expected) { - throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") - } - jars.each { candidate -> - if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") - new ZipFile(candidate).withCloseable { zip -> - zip.entries().findAll { entry -> - !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } - || entry.name.endsWith('/element-list') - || entry.name.endsWith('/package-list')) - }.each { entry -> - boolean cr = zip.getInputStream(entry).withCloseable { - input -> input.bytes.any { value -> value == 13 } - } - if (cr) throw new GradleException("${candidate.name}!/${entry.name} is not LF-normalized") - } - [ - 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', - 'src/benchmarkIntegrationTest/', 'agent-notes/', 'surfaceprobe', - 'clientprobe', 'benchmarkprobe', 'ci-fixtures/', 'net/minecraftforge/', - 'META-INF/mods.toml', 'org/junit/', 'org/mockito/', 'net/bytebuddy/', - 'Mineralogy-1.18.2-5.4.0.jar' - ].each { forbidden -> - if (zip.entries().any { it.name.contains(forbidden) }) { - throw new GradleException("${candidate.name} contains forbidden ${forbidden}") - } - } - } - } - - File mainJar = new File(libs, expectedReleaseFiles.get()[0]) - new ZipFile(mainJar).withCloseable { zip -> - List names = zip.entries().collect { it.name } - [ - 'META-INF/neoforge.mods.toml', - 'META-INF/accesstransformer.cfg', - 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', - 'META-INF/orespawn/docs/VERSIONS.md', - 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', - 'AGENTS.md' - ].each { required -> - if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}") - } - String metadata = zip.getInputStream(zip.getEntry('META-INF/neoforge.mods.toml')) - .getText(StandardCharsets.UTF_8.name()) - if (!metadata.contains('modId="orespawn"') - || !metadata.contains("version=\"${project.version}\"") - || !metadata.contains('versionRange="[26.3.0.1-beta,26.4)"')) { - throw new GradleException('Packaged NeoForge metadata is incorrect') - } - String transformer = zip.getInputStream(zip.getEntry('META-INF/accesstransformer.cfg')) - .getText(StandardCharsets.UTF_8.name()) - List actualRules = transformer.readLines() - .collect { it.replaceFirst(/\s*#.*/, '').trim() }.findAll { !it.isEmpty() } - List expectedRules = [ - 'public-f net.minecraft.world.level.chunk.ChunkGenerator biomeSource', - 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator globalFluidPicker', - 'public-f net.minecraft.world.level.biome.Biome generationSettings', - 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabManager', - 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabNavigationBar' - ] - if (actualRules != expectedRules) { - throw new GradleException("Unexpected packaged official-named access transformer: ${actualRules}") - } - def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') - def manifest = manifestEntry == null ? null : - new Manifest(zip.getInputStream(manifestEntry)).mainAttributes - if (manifest == null - || manifest.getValue('Implementation-Version') != project.mod_version - || manifest.getValue('OreSpawn-API-Version') != '1' - || manifest.getValue('OreSpawn-Loader') != 'neoforge' - || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate - || manifest.getValue('Implementation-Timestamp') != null) { - throw new GradleException('Release manifest is incorrect or volatile') - } - zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> - byte[] header = zip.getInputStream(entry).withCloseable { it.readNBytes(8) } - int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) - if (major != 69) throw new GradleException("${entry.name} uses class major ${major}, expected 69") - } - } - new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> - if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { - throw new GradleException('Sources jar is missing OreSpawn.java') - } - } - new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> - if (zip.getEntry('index.html') == null - || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { - throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') - } - } - } -} - -tasks.register('writeReleaseChecksums') { - group = 'verification' - dependsOn tasks.named('verifyReleaseArtifacts') - def outputFile = layout.buildDirectory.file('release/SHA256SUMS') - outputs.file outputFile - doLast { - File output = outputFile.get().asFile - output.parentFile.mkdirs() - File libs = layout.buildDirectory.dir('libs').get().asFile - String contents = expectedReleaseFiles.get().sort().collect { name -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - new File(libs, name).withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" - }.join('\n') + '\n' - output.setText(contents, 'UTF-8') - } -} - -tasks.register('verifyPreparedReleaseArtifacts') { - group = 'verification' - doLast { - if (!preparedReleaseDir.isPresent()) throw new GradleException('preparedReleaseDir is required') - File prepared = file(preparedReleaseDir.get()) - List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() - List jars = (prepared.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') }.sort { it.name } - if (jars.collect { it.name } != expected || jars.any { it.length() == 0L }) { - throw new GradleException("Prepared release jars ${jars*.name} do not match ${expected}") - } - File checksums = new File(prepared, 'SHA256SUMS') - if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { - throw new GradleException('Prepared release is missing checksums or changelog') - } - } -} - -def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') - .orElse('https://invalid.invalid/missing-maven-upload-url') -def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') -def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') -publishing { - publications { - mavenJava(MavenPublication) { - groupId = expectedMavenGroup - artifactId = expectedMavenArtifact - version = project.version.toString() - if (preparedReleaseDir.isPresent()) { - File prepared = file(preparedReleaseDir.get()) - artifact(new File(prepared, expectedReleaseFiles.get()[0])) - artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } - artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } - } else { - from components.java - } - pom { - name = 'MMD OreSpawn' - description = project.mod_description - url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' - licenses { - license { - name = 'GNU Lesser General Public License, Version 2.1' - url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' - } - } - } - } - } - repositories { - maven { - name = 'release' - url = uri(mavenUploadUrl.get()) - credentials { - username = mavenUploadUsername.orNull ?: '' - password = mavenUploadPassword.orNull ?: '' - } - } - } -} - -tasks.register('verifyMavenCoordinates') { - group = 'verification' - dependsOn tasks.named('generatePomFileForMavenJavaPublication') - doLast { - File pomFile = layout.buildDirectory.file( - 'publications/mavenJava/pom-default.xml').get().asFile - def pom = new XmlSlurper(false, false).parse(pomFile) - def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] - def expected = [expectedMavenGroup, expectedMavenArtifact, project.version.toString()] - if (project.group.toString() != expectedMavenGroup || actual != expected) { - throw new GradleException("Expected Maven coordinate ${expected.join(':')}, found ${actual.join(':')}") - } - } -} - -tasks.register('validateMavenReleaseCredentials') { - group = 'publishing' - doLast { - if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() - || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { - throw new GradleException( - 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') - } - if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { - throw new GradleException('Release publication must use a remote repository') - } - } -} -tasks.withType(PublishToMavenRepository).configureEach { - dependsOn tasks.named('validateMavenReleaseCredentials') - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn preparedReleaseDir.isPresent() - ? tasks.named('verifyPreparedReleaseArtifacts') - : tasks.named('verifyReleaseArtifacts') -} - -tasks.register('verifyCommandPortability') { - group = 'verification' - description = 'Rejects shell-specific wrappers in generic Gradle task execution.' - doLast { - List commandSources = [file('build.gradle')] - commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) - commandSources.each { File source -> - String text = source.getText('UTF-8') - if (text =~ /(?i)(?:commandLine|executable)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { - throw new GradleException("Shell-specific command wrapper in ${source}") - } - } - } -} - -tasks.register('verifyColdNeoForgeBootstrapContract') { - group = 'verification' - description = 'Verifies that CI qualifies NeoGradle from an isolated Java 25 cache without Forge tooling or forced source decompilation.' - doLast { - Properties gradleProperties = new Properties() - file('gradle.properties').withInputStream { gradleProperties.load(it) } - if (gradleProperties.getProperty('neogradle.subsystems.decompiler.enabled') != null) { - throw new GradleException('NeoForge 26.3 must retain its validated binary userdev path') - } - - String workflow = file('.github/workflows/ci.yml').getText('UTF-8') - [ - 'Cold NeoForge bootstrap', - 'orespawn-neoforge-cold-cache', - '--no-build-cache', - '--offline', - './gradlew classes verifyLegacyFixtures --no-daemon', - './gradlew classes verifyLegacyFixtures --offline --no-daemon', - './gradlew check build javadoc', - "java-version: '25.0.3+9.0.LTS'" - ].each { required -> - if (!workflow.contains(required)) { - throw new GradleException("Cold NeoForge bootstrap is missing ${required}") - } - } - ['MinecraftMavenizer', 'mavenizer', 'net.minecraftforge.gradle', - 'neogradle.subsystems.decompiler.enabled=true', - "java-version: '21", "java-version: '17", "java-version: '8"].each { forbidden -> - if (workflow.contains(forbidden)) { - throw new GradleException("Cold NeoForge bootstrap contains Forge-only tooling: ${forbidden}") - } - } - ['./gradlew clean check', './gradlew clean classes'].each { combined -> - if (workflow.contains(combined)) { - throw new GradleException( - "NeoGradle clean must run separately from model consumers: ${combined}") - } - } - - String codeQlWorkflow = file('.github/workflows/codeql-analysis.yml').getText('UTF-8') - if (!codeQlWorkflow.contains('./gradlew clean --no-daemon') - || codeQlWorkflow.contains('clean classes')) { - throw new GradleException( - 'CodeQL must clean once before its cache-preserving compilation retry loop') - } - } -} - -tasks.named('check') { - dependsOn tasks.named('verifyCommandPortability') - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn tasks.named('verifyColdNeoForgeBootstrapContract') -} - -idea { - module { - downloadSources = true - downloadJavadoc = true - } -} - -eclipse { - project { - natures 'org.eclipse.buildship.core.gradleprojectnature' - buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder' - } - classpath { - downloadSources = true - downloadJavadoc = true - defaultOutputDir = file('bin/default') - file { - whenMerged { classpath -> - def libraryPaths = new LinkedHashSet() - classpath.entries = classpath.entries.findAll { entry -> - !(entry instanceof org.gradle.plugins.ide.eclipse.model.Library) - || libraryPaths.add(entry.path) - } - classpath.entries.findAll { entry -> - entry instanceof org.gradle.plugins.ide.eclipse.model.Output - }.each { entry -> - entry.path = 'bin/default' - } - classpath.entries.removeAll { entry -> - entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder - && ['src/main/resources', 'src/generated/resources'].contains(entry.path) - } - if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { - classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( - 'build/resources/main', 'bin/main')) - } - classpath.entries.findAll { entry -> - entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder - }.each { entry -> - if (entry.path == 'build/resources/main' || entry.path == 'src/main/java') { - entry.output = 'bin/main' - } else if (entry.path.startsWith('src/test/')) { - entry.output = 'bin/test' - } - } - } - } - } - synchronizationTasks 'prepareEclipseResources' -} - -tasks.named('eclipse').configure { - dependsOn prepareEclipseResources - doLast { - String requestedGradleHome = System.getenv('GRADLE_USER_HOME') - File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') - Properties preferences = new Properties() - if (preferencesFile.isFile()) { - preferencesFile.withInputStream { preferences.load(it) } - } - String existingGradleHome = preferences.getProperty('connection.gradle.user.home', '').trim() - File gradleHome = requestedGradleHome != null && !requestedGradleHome.trim().isEmpty() - ? file(requestedGradleHome) - : (!existingGradleHome.isEmpty() ? file(existingGradleHome) : gradle.gradleUserHomeDir) - preferences.setProperty('eclipse.preferences.version', '1') - preferences.setProperty('connection.gradle.distribution', 'GRADLE_DISTRIBUTION(WRAPPER)') - preferences.setProperty('override.workspace.settings', 'true') - preferences.setProperty('gradle.user.home', gradleHome.canonicalPath) - preferences.setProperty('connection.gradle.user.home', gradleHome.canonicalPath) - preferences.setProperty('java.home', System.getProperty('java.home')) - preferencesFile.parentFile.mkdirs() - preferencesFile.withOutputStream { - preferences.store(it, 'Generated by the OreSpawn Eclipse task to keep NeoGradle launch modules on one cache.') - } - } -} - -tasks.register('verifyEclipseProductionClasspath') { - group = 'verification' - dependsOn tasks.named('eclipse') - dependsOn tasks.named('verifyLegacyOracleIsolation') - doLast { - File prefs = file('.settings/org.eclipse.buildship.core.prefs') - if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') - Properties preferences = new Properties() - prefs.withInputStream { preferences.load(it) } - if (preferences.getProperty('override.workspace.settings') != 'true') { - throw new GradleException('Eclipse Buildship workspace override is not active') - } - String eclipseJavaHome = preferences.getProperty('java.home', '').trim() - if (eclipseJavaHome.isEmpty() || !new File(eclipseJavaHome, 'release').isFile()) { - throw new GradleException("Eclipse Buildship Java home is missing or invalid: ${eclipseJavaHome}") - } - String eclipseJavaRelease = new File(eclipseJavaHome, 'release').getText('UTF-8') - if (!eclipseJavaRelease.contains('JAVA_VERSION="25.0.3"') - || !eclipseJavaRelease.contains('IMPLEMENTOR="Eclipse Adoptium"')) { - throw new GradleException( - "Eclipse Buildship must use exact Temurin 25.0.3+9: ${eclipseJavaHome}") - } - File eclipseClasspath = file('.classpath') - if (!eclipseClasspath.isFile()) throw new GradleException('Eclipse .classpath was not generated') - String classpathText = eclipseClasspath.getText('UTF-8') - if (!classpathText.contains('path="build/resources/main"') - || classpathText.contains('path="src/main/resources"') - || classpathText.contains('path="src/generated/resources"')) { - throw new GradleException('Eclipse must consume only Gradle-processed production resources') - } - def classpathXml = new XmlSlurper(false, false).parse(eclipseClasspath) - String defaultOutput = classpathXml.classpathentry - .find { it.@kind.text() == 'output' }.@path.text() - Map expectedSourceOutputs = [ - 'src/main/java' : 'bin/main', - 'build/resources/main' : 'bin/main', - 'src/test/java' : 'bin/test', - 'src/test/resources' : 'bin/test' - ] - List invalidSourceOutputs = classpathXml.classpathentry - .findAll { it.@kind.text() == 'src' } - .collect { [it.@path.text(), it.@output.text()] } - .findAll { pair -> - String expected = expectedSourceOutputs[pair[0]] - expected != null && pair[1] != expected - } - .collect { pair -> "${pair[0]} -> ${pair[1]}" } - boolean nestedOutput = classpathXml.classpathentry - .findAll { it.@kind.text() == 'src' && !it.@output.text().isEmpty() } - .any { entry -> - String output = entry.@output.text() - output == defaultOutput || output.startsWith(defaultOutput + '/') - } - if (defaultOutput != 'bin/default' || !invalidSourceOutputs.isEmpty() - || nestedOutput || classpathText.contains('output="build/sourceSets')) { - throw new GradleException( - "Eclipse outputs must be disjoint bin/default, bin/main and bin/test " + - "directories; default=${defaultOutput}, invalid=${invalidSourceOutputs}") - } - ['META-INF/neoforge.mods.toml', 'META-INF/orespawn/docs/README.md', - 'META-INF/orespawn/docs/VERSIONS.md'].each { relative -> - if (!new File('bin/main', relative).isFile()) { - throw new GradleException("Eclipse output is missing ${relative}") - } - } - File metadata = file('bin/main/META-INF/neoforge.mods.toml') - String metadataText = metadata.getText('UTF-8') - if (metadataText.contains('\${mod_version}') - || !metadataText.contains("version=\"${project.version}\"")) { - throw new GradleException('Eclipse output contains unexpanded NeoForge metadata') - } - List forbidden = [ - 'src/test', 'bin/test', 'build/classes/java/test', - 'biomeIntegrationTest', 'clientIntegrationTest', 'benchmarkIntegrationTest', - 'surfaceprobe', 'clientprobe', 'benchmarkprobe', 'junit-', 'opentest4j-', - 'Mineralogy-1.18.2-5.4.0.jar', 'net.minecraftforge', 'META-INF/mods.toml', - 'C:\\Users\\John' - ] - fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> - List leaked = forbidden.findAll { launch.getText('UTF-8').contains(it) } - if (!leaked.isEmpty()) { - throw new GradleException("${launch.name} exposes test/Forge/local content: ${leaked}") - } - } - } -} +apply from: 'gradle/verification/support.gradle' +apply from: 'gradle/verification/client-and-benchmark.gradle' +apply from: 'gradle/verification/ore-sources.gradle' +apply from: 'gradle/verification/biome-override.gradle' +apply from: 'gradle/release/artifacts.gradle' +apply from: 'gradle/verification/native-ores.gradle' +apply from: 'gradle/release/publishing.gradle' +apply from: 'gradle/verification/contracts.gradle' +apply from: 'gradle/ide/eclipse.gradle' diff --git a/ci-fixtures/config/neoforge-26.3-fml.toml b/ci-fixtures/config/neoforge-26.3-fml.toml new file mode 100644 index 00000000..7d4438e0 --- /dev/null +++ b/ci-fixtures/config/neoforge-26.3-fml.toml @@ -0,0 +1,14 @@ +# Stock FML 12.0.0 settings for disposable test runs, not for manual profiles. +disableConfigWatcher = false +earlyWindowControl = true +maxThreads = -1 +versionCheck = true +debugOpenGl = false +defaultConfigPath = "defaultconfigs" +disableOptimizedDFU = true +earlyWindowProvider = "fmlearlywindow" +earlyWindowWidth = 854 +earlyWindowHeight = 480 +earlyWindowMaximized = false +earlyLoadingScreenTheme = "" +dependencyOverrides = {} diff --git a/docs/AGENTS.md b/docs/AGENTS.md deleted file mode 100644 index 756056ab..00000000 --- a/docs/AGENTS.md +++ /dev/null @@ -1,17 +0,0 @@ -# OreSpawn Documentation Map - -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: - -- [API.md](API.md) for the supported Java API; -- [PROVIDERS.md](PROVIDERS.md) for packaged and configurable providers; -- [FEATURES.md](FEATURES.md) for rocks, ores, deposits, and geology; -- [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, skipped functional releases, 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 c022c640..6e4e5478 100644 --- a/docs/API.md +++ b/docs/API.md @@ -45,8 +45,22 @@ coverage with `OreDefinition.Builder.dimensionSelector(...)` and `OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END`. Explicit dimensions override that selector and prevent duplicate placement. -The builder emits provider schema 4. Legacy provider schemas 1-3 remain -readable. Schema 4 is required for biome palettes and dimension materials. +The builder emits provider schema 5. Legacy provider schemas 1-4 remain +readable. Schema 4 is required for biome palettes and dimension materials; +schema 5 adds explicit material and placement-channel IDs. API major remains 1. + +For custom ore patterns, `OreGenerationContext` extends the existing +`OrePlacementContext` without changing its binary contract. It exposes the +world seed, dimension, chunk coordinates, and an optional geology sampler. +The `tryPlace(x, y, z, outputIdentity)` overload gives separated slices of one +logical deposit the same output choice. Code built against the older context +continues to work through the original `tryPlace` method. + +Client add-ons may register one optional configuration screen per mod through +`WorldSettingsExtensionRegistry.registerConfigScreen(modId, factory)` during +client initialization. OreSpawn shows it in the loaded-mod directory and +returns Escape to that directory. Do not load this client-only API on a +dedicated server. Provider-owned fluid deposits are declarative and may target several dimensions: diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 66012d05..8a93eacd 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -39,7 +39,7 @@ and OreSpawn warns once while baking. ```json { - "schema_version": 4, + "schema_version": 5, "provider_modid": "cakeworld", "provider_revision": 1, "biome_palettes": { @@ -172,10 +172,18 @@ then lexical template ID order. ## World-Creation Editor -**Biomes & World Materials** is visible even when rock strata are disabled. -It lists palettes and materials by dimension, uses installed-registry pickers, -and validates IDs before world creation. The editor is creation-only in 4.0.0; -existing worlds remain editable through their self-contained server profile. +**Biomes** is visible even when rock strata are disabled. Its directory shows +loaded and referenced biomes once, with provider placement rules and effective +palette order. It can replace one loaded source biome with another in newly +generated terrain. The exact replacement layer runs after ordinary palettes; +existing chunks are never rewritten. A missing target stays dormant until its +mod returns. Palette settings expose mode, scope, region size, coverage, +fallback weight, and namespace filters without reordering provider palettes. + +The materials button belongs to the selected dimension, not the selected +biome. Aquifer fluid, snow, and ice substitutions apply across every biome in +that dimension. The deep-aquifer field is available where the target generator +exposes that fluid separately. ## Performance Boundaries diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 33235d97..94fcd215 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,13 +4,24 @@ OreSpawn uses three JSON contracts: | File | Schema | Purpose | |---|---:|---| -| `config/orespawn-worldgen.json` | 6 | Installed-pack defaults for new worlds | -| `/serverconfig/orespawn-worldgen.json` | 5 | Self-contained snapshot for one world | -| `config/-orespawn.json` | 4 | Optional authoritative provider override | +| `config/orespawn-worldgen.json` | 8 | Installed-pack defaults for new worlds | +| `/serverconfig/orespawn-worldgen.json` | 7 | Self-contained snapshot for one world | +| `config/-orespawn.json` | 5 | Optional authoritative provider override | -A provider may package schema 4 at `data//orespawn/provider.json`. -Legacy provider schemas 1-3 remain accepted. Fluid deposits require schema 3; -biome palettes and dimension materials require schema 4. +The small bootstrap config remains `config/orespawn-common.toml`. NeoForge +26.3.0.37-beta renamed its unsynced `COMMON` config type to `LOCAL`; OreSpawn +supports both names without renaming the file or changing saved settings. +You do not need to move your config when updating NeoForge. + +New geome IDs must already use valid lowercase spelling. The editor rejects +invalid or duplicate IDs without changing the profile; it does not silently +rename them. Namespaced IDs such as `example:cocoa_basin` are recommended, +while existing lowercase legacy IDs remain supported. + +A provider may package schema 5 at `data//orespawn/provider.json`. +Legacy provider schemas 1-4 remain accepted. Fluid deposits require schema 3; +biome palettes and dimension materials require schema 4. Explicit ore material +and placement-channel IDs require schema 5. The profile for a new world is merged in this order: passive OreSpawn defaults, packaged or API providers, provider override files, the global configuration, @@ -29,7 +40,7 @@ report for the exact decision. | Field | Values | Meaning | |---|---|---| -| `schema_version` | Contract-specific integer | Global 6, world 5, provider 4 | +| `schema_version` | Contract-specific integer | Global 8, world 7, provider 5 | | `geology_mode` | `geome`, `legacy` | Sky/geome engine or Cyano legacy engine | | `place_fluid_deposits` | boolean | Master switch for configured fluid-deposit rules | | `manage_vanilla_ores` | boolean | Lets OreSpawn suppress and replace claimed vanilla ore features | @@ -44,6 +55,8 @@ report for the exact decision. | `biome_palettes` | object keyed by provider-owned rule ID | Optional native-biome overlays and surfaces | | `dimension_materials` | object keyed by provider-owned rule ID | Aquifer fluid, snow, and ice substitutions | | `ores` | object keyed by rule ID | Ore outputs and per-dimension placement | +| `ore_material_groups` | object keyed by material ID | Friendly names and exact block-tag aliases; imported Ore Dictionary names stay dormant | +| `ore_source_policies` | object keyed by material and dimension | Saved output mode, weights, and placement source per channel | | `fluid_deposits` | object keyed by rule ID | Provider-owned fluids and per-dimension placement | | `retrogen` | object | Bounded ore retrogen controls | | `flat_bedrock` | object | Opt-in flat bedrock controls | @@ -178,6 +191,7 @@ Each enabled ore dimension uses: | `node_size` | 1-32 | Cluster node size | | `length` | 1-64 | Pattern path length where supported | | `fluid` | registry ID | Fluid used by `underfluids` | +| `placement_channel` | registry ID | Independent placement budget shared by equivalent ores in consolidated modes | At least one of `host_families`, `host_blocks`, or `host_tags` must be present. Hosts may be plain registry IDs or weighted objects such as @@ -195,6 +209,30 @@ The selector `orespawn:all_except_nether_end` covers every dimension except the vanilla Nether and End. Explicit rules in `dimensions` override selector rules for the same ore and dimension, including explicit disabled rules. +### Material groups and source policies + +Provider schema 5 may add a `material` ID to an ore. Otherwise OreSpawn infers +the material only from exact loaded block tags under `*/ores/*`; it does not +guess from a block or mod name. `ore_material_groups` stores a friendly name +and `block_tag_entries` for each group. Imported `ore_dictionary_entries` +remain dormant unless a user gives the group an exact tag mapping. + +`ore_source_policies` stores one material-and-dimension decision: Keep Original +(`keep_separate`) or consolidated Balanced, Single, or Custom output selection. +The `placement_sources` object names one source rule for each placement +channel. Consolidation uses that rule's frequency, shape, depth, and hosts, +then picks the selected output for a whole deposit. It does not give every +output its own placement budget. External generators are shown for context but +cannot be suppressed by OreSpawn. + +For a newly created world, two loaded ordinary MMD providers with the same +exact ore tag start Balanced. A configured Sulfur or Lithium priority chooses +the initial placement source; other conflicts use stable owner/rule-ID order. +Missing providers create no phantom ores. Existing worlds retain their saved +policy, defaulting to Keep Original on upgrade. Both global and world profile +upgrades keep a backup; editing the global defaults in the UI is saved only +when the main OreSpawn editor's Done is pressed. + ## Fluid Deposits, Retrogen, And Bedrock Each `fluid_deposits` entry has a stable provider-namespaced rule ID, an diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index d6d65513..9615a9a1 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -1,5 +1,18 @@ # OreSpawn Developer Guide +## Testing shared ores on this branch + +Run `prepareOreSourcesClient`, then use the separate Ore Sources client launch +generated by the Eclipse task. Its two test-only providers demonstrate shared +ore tags and inactive output choices without adding fixture content to release +jars or the ordinary client profile. `oreSourcesClientIntegrationProcess` +checks the same profile automatically, including fresh-world and reload rendering. + +`nativeOreIntegrationTest` checks managed Nether quartz and tag-only fluid +deposits with an official packaged NeoForge server. It compares tag and explicit +hosts, preserves original chunks after a rule edit, and checks wrapper cleanup +after shutdown. These gates use disposable worlds, not player saves. + ## Decide Which Integration You Need | Goal | Recommended integration | @@ -12,6 +25,8 @@ | Add covered underground oil or another fluid | Provider schema 3 fluid deposit | | Add or place biomes without a framework dependency | Provider schema 4 biome palette | | Replace surfaces, aquifers, snow, or ice | Provider schema 4 dimension materials | +| Share placement between equivalent ore blocks | Provider schema 5 material and placement channel, with one exact block tag | +| Add an OreSpawn-linked settings screen | Client-only `WorldSettingsExtensionRegistry` | | Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` | Strata are optional. If no enabled terrain dimension has eligible rocks, @@ -21,7 +36,7 @@ blocks or tags. ## Provider JSON Quick Start -Put a schema-4 file in your mod jar at: +Put a schema-5 file in your mod jar at: ```text src/main/resources/data/examplemod/orespawn/provider.json @@ -33,7 +48,7 @@ stone without enabling strata: ```json { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "ores": { diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 08a587f4..5f4466c8 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -25,8 +25,10 @@ player deliberately adds them. You can remove the starter rocks or add blocks from installed mods before creating the world. Mods can also offer new biomes and world materials without enabling strata. -Use **Biomes & World Materials** to inspect installed dimension palettes, -surface blocks, aquifer fluids, snow, and ice. The picker only accepts real +Use **Biomes** to inspect loaded biomes, placement palettes, and surface rules. +Replacing a biome changes only newly generated terrain. The dimension's +**Materials** button changes aquifer fluid, snow, or ice across every biome in +that dimension, not only the biome currently selected. The picker only accepts installed registry entries. Missing optional compatibility biomes are skipped safely instead of breaking world creation. @@ -48,11 +50,13 @@ safely instead of breaking world creation. installed mod. These are covered underground deposits, not exposed vanilla lakes. **Solid Cover** controls the roof thickness, while **Solid Shell** prevents a deposit from opening into a cave at its sides or underside. -- **Biomes & World Materials** controls broad biome regions and what their - surfaces, underground water, snow, and ice are made from. **Augment** mixes - new biomes into the existing source; **Replace** creates a complete provider - style. Namespace scope protects other biome mods unless a pack deliberately - opts them in. +- **Biomes** lists installed and referenced biomes and their placement rules. + **Replace in new terrain with...** is an exact, one-to-one choice and does + not rewrite existing chunks. Palette settings still offer **Augment** for a + provider mix or **Replace** for a complete provider style. +- **Ore Sources** groups interchangeable ores by exact block tags. Balanced + shares one placement budget across eligible outputs; Single picks one output; + Custom lets you choose weights. Keep Original leaves every rule independent. **World Materials** applies across an entire dimension. **Aquifer Fluid** changes the normal below-sea-level fluid, while **Deep Aquifer** can use a diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index f3bc3f83..a66be26c 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -6,12 +6,20 @@ Provider mods may contribute through NeoForge IMC, a packaged resource at present malformed override leaves that provider inactive instead of silently falling back. -Provider schema 4 supports `profile_defaults`, `rocks`, `ores`, +Provider schema 5 supports `profile_defaults`, `rocks`, `ores`, `fluid_deposits`, `geomes`, `biome_rules`, `terrain_dimensions`, and `templates`, plus `biome_palettes` and `dimension_materials`. Each file requires a matching `provider_modid`, a positive `provider_revision`, and at least one -contribution. Legacy schemas 1-3 remain accepted; schema 3 introduced fluid -deposits and schema 4 introduces biome and world-material controls. +contribution. Legacy schemas 1-4 remain accepted; schema 3 introduced fluid +deposits, schema 4 added biome and world-material controls, and schema 5 adds +optional `material` and `placement_channel` IDs for exact ore grouping. + +Use the same exact loaded block tag, such as `c:ores/sulfur`, for ores that +are interchangeable. OreSpawn never infers equivalence from similar block or +mod names. When two ordinary MMD providers for the same tag are loaded, a new +world starts Balanced with one placement budget per channel. An absent mod +adds no phantom output or placement rule, and old worlds keep their saved +choices. Enrichment and dimension-specific providers are kept separate. An ore-only provider does not need rocks, geomes, or terrain dimensions. Give each ore explicit host blocks or tags and OreSpawn will leave vanilla terrain, diff --git a/docs/README.md b/docs/README.md index e1462cb8..09b222a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,10 @@ OreSpawn is a required NeoForge mod and a declarative world-generation engine. The normal jar is both the compile-time and runtime dependency; there is no shaded or embeddable engine artifact. -Choose the guide that matches what you are doing: +Start with the [developer guide](DEVELOPER_GUIDE.md) when integrating a mod, +or the [player and server guide](PLAYER_GUIDE.md) when configuring a world. +The API and provider guides give the exact Java and JSON contracts. Use the +versioning guide when choosing a matching Minecraft and loader build. - [Player and server guide](PLAYER_GUIDE.md) - [Developer quick start and complete integration map](DEVELOPER_GUIDE.md) @@ -18,7 +21,6 @@ Choose the guide that matches what you are doing: - [Migration](MIGRATION.md) - [Troubleshooting](TROUBLESHOOTING.md) - [Versioning and release conventions](VERSIONS.md) -- [Compact instructions for coding agents](AGENTS.md) Validated examples are in `examples/`; JSON Schemas are in `schemas/`. The provider and migration guides include OS3-compatible ranged quantities and diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 084773f8..fa484a88 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -69,7 +69,7 @@ Examples: | 26.1.2 | NeoForge | `2601022` | `4.0.16.2601022` | | 26.2 | Forge | `2602001` | `4.0.16.2602001` | | 26.2 | NeoForge | `2602002` | `4.0.16.2602002` | -| 26.3 | NeoForge | `2603002` | `4.0.16.2603002` | +| 26.3 | NeoForge | `2603002` | `4.1.0.2603002` | Historical MMD releases may also have four numeric components but may have used the fourth component differently. This policy applies prospectively; it does @@ -132,7 +132,7 @@ For example: Minecraft 26.1.2 / Forge / OreSpawn 4.0.6.2601021 Minecraft 26.2 / Forge / OreSpawn 4.0.6.2602001 Minecraft 26.2 / NeoForge / OreSpawn 4.0.6.2602002 -Minecraft 26.3 / NeoForge / OreSpawn 4.0.6.2603002 +Minecraft 26.3 / NeoForge / OreSpawn 4.1.0.2603002 ``` Target-specific implementation details may differ internally where Minecraft diff --git a/docs/examples/examplemod-orespawn.json b/docs/examples/examplemod-orespawn.json index 1c8279df..ad07ab5c 100644 --- a/docs/examples/examplemod-orespawn.json +++ b/docs/examples/examplemod-orespawn.json @@ -1,5 +1,5 @@ { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "rocks": { diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json index cabcfee1..6710b185 100644 --- a/docs/examples/orespawn-global.json +++ b/docs/examples/orespawn-global.json @@ -1,5 +1,5 @@ { - "schema_version": 6, + "schema_version": 8, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -32,6 +32,8 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": {}, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json index 07351678..f379edfb 100644 --- a/docs/examples/orespawn-world.json +++ b/docs/examples/orespawn-world.json @@ -1,5 +1,5 @@ { - "schema_version": 5, + "schema_version": 7, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -70,6 +70,8 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": {}, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/schemas/orespawn-global.schema.json b/docs/schemas/orespawn-global.schema.json index e20e5774..49022e13 100644 --- a/docs/schemas/orespawn-global.schema.json +++ b/docs/schemas/orespawn-global.schema.json @@ -6,7 +6,7 @@ { "type": "object", "required": ["schema_version"], - "properties": { "schema_version": { "const": 6 } } + "properties": { "schema_version": { "const": 8 } } } ] } diff --git a/docs/schemas/orespawn-provider.schema.json b/docs/schemas/orespawn-provider.schema.json index 83f67f7b..1416edfa 100644 --- a/docs/schemas/orespawn-provider.schema.json +++ b/docs/schemas/orespawn-provider.schema.json @@ -4,7 +4,7 @@ "type": "object", "required": ["schema_version", "provider_modid", "provider_revision"], "properties": { - "schema_version": { "enum": [1, 2, 3, 4] }, + "schema_version": { "enum": [1, 2, 3, 4, 5] }, "provider_modid": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]+$" }, "provider_revision": { "type": "integer", "minimum": 1 }, "rocks": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/rock" } }, diff --git a/docs/schemas/orespawn-world.schema.json b/docs/schemas/orespawn-world.schema.json index f23b7970..497cc4a0 100644 --- a/docs/schemas/orespawn-world.schema.json +++ b/docs/schemas/orespawn-world.schema.json @@ -6,7 +6,7 @@ { "type": "object", "required": ["schema_version"], - "properties": { "schema_version": { "const": 5 } } + "properties": { "schema_version": { "const": 7 } } } ] } diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json index 6d99c56c..c76a11e9 100644 --- a/docs/schemas/profile-common.schema.json +++ b/docs/schemas/profile-common.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://mcmoddev.com/orespawn/schemas/profile-common.schema.json", "type": "object", - "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "fluid_deposits"], + "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "ore_material_groups", "ore_source_policies", "fluid_deposits"], "properties": { "geology_mode": { "enum": ["geome", "legacy"] }, "place_fluid_deposits": { "type": "boolean" }, @@ -19,6 +19,8 @@ "biome_palettes": { "type": "object", "additionalProperties": { "$ref": "#/$defs/biomePalette" } }, "dimension_materials": { "type": "object", "additionalProperties": { "$ref": "#/$defs/dimensionMaterials" } }, "ores": { "type": "object", "additionalProperties": { "$ref": "#/$defs/ore" } }, + "ore_material_groups": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "$ref": "#/$defs/oreMaterialGroup" } }, + "ore_source_policies": { "type": "object", "additionalProperties": { "$ref": "#/$defs/oreSourcePolicy" } }, "fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "#/$defs/fluidDeposit" } }, "worldgen_aliases": { "type": "object", "additionalProperties": { "$ref": "#/$defs/registryId" } }, "providers": { "type": "object" }, @@ -145,6 +147,7 @@ "minimum": 0.0, "maximum": 1.0 }, + "placement_channel": { "$ref": "#/$defs/registryId" }, "spread": { "type": "integer", "minimum": 0, "maximum": 64 }, "vertical_spread": { "type": "integer", "minimum": 0, "maximum": 64 }, "node_size": { "type": "integer", "minimum": 1, "maximum": 32 }, @@ -181,6 +184,7 @@ "required": ["enabled"], "properties": { "block": { "$ref": "#/$defs/registryId" }, + "material": { "$ref": "#/$defs/registryId" }, "enabled": { "type": "boolean" }, "source_mod": { "type": "string" }, "native_generation": { "type": "boolean" }, @@ -217,6 +221,56 @@ ], "additionalProperties": true }, + "oreSourceCandidate": { + "type": "object", + "required": ["source_id", "owner", "registry_id", "material", "domain", "placement_channel", "loaded", "external"], + "properties": { + "source_id": { "type": "string", "minLength": 1 }, + "owner": { "type": "string", "minLength": 1 }, + "owner_name": { "type": "string" }, + "owner_version": { "type": "string" }, + "registry_id": { "$ref": "#/$defs/registryId" }, + "metadata": { "type": "integer", "minimum": 0 }, + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "placement_channel": { "$ref": "#/$defs/registryId" }, + "block_tags": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/registryId" } }, + "loaded": { "type": "boolean" }, + "placement_active": { "type": "boolean" }, + "external": { "type": "boolean" }, + "enrichment": { "type": "boolean" }, + "review_required": { "type": "boolean" }, + "material_declared": { "type": "boolean" } + }, + "additionalProperties": false + }, + "oreSourcePolicy": { + "type": "object", + "required": ["material", "domain", "mode", "output_mode", "outputs", "placement_sources", "candidates"], + "properties": { + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "mode": { "enum": ["consolidated", "keep_separate"] }, + "output_mode": { "enum": ["balanced", "single", "custom"] }, + "outputs": { "type": "object", "additionalProperties": { "type": "number", "exclusiveMinimum": 0 } }, + "placement_sources": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "type": "string", "minLength": 1 } }, + "candidates": { "type": "array", "items": { "$ref": "#/$defs/oreSourceCandidate" } }, + "review_required": { "type": "boolean" }, + "status": { "enum": ["consolidated", "separate", "review_required", "missing_source", "external_generation"] }, + "dormant": { "type": "boolean" } + }, + "additionalProperties": false + }, + "oreMaterialGroup": { + "type": "object", + "required": ["display_name", "block_tag_entries"], + "properties": { + "display_name": { "type": "string", "minLength": 1, "maxLength": 64 }, + "block_tag_entries": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/registryId" } }, + "ore_dictionary_entries": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } } + }, + "additionalProperties": false + }, "fluidDepositDimension": { "type": "object", "required": ["enabled", "min_y", "max_y", "frequency", "min_radius", "max_radius", "min_vertical_radius", "max_vertical_radius", "max_lobes", "min_solid_cover"], diff --git a/gradle.properties b/gradle.properties index e988ce46..a4f84678 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,7 +35,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.16.2603002 +mod_version=4.1.0.2603002 # 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/gradle/README.md b/gradle/README.md new file mode 100644 index 00000000..3792a111 --- /dev/null +++ b/gradle/README.md @@ -0,0 +1,57 @@ +# Build scripts + +`build.gradle` keeps the target version, NeoGradle runs, dependencies, resources +and Java defaults in one place. The smaller scripts below own the longer checks: + +- `verification/support.gradle` checks sealed test fixtures, protects a + developer's local config during tests and reviews complete runtime logs. +- `verification/contracts.gradle` checks target and workflow assumptions. +- `verification/client-and-benchmark.gradle` builds the isolated surface, + client and benchmark probes used in development runs. +- `verification/ore-sources.gradle` builds two test-only future MMD providers + and checks their shared ore policy on fresh and reloaded worlds. +- `verification/biome-override.gradle` checks exact biome replacements in new + terrain without changing a previously saved chunk. +- `verification/native-ores.gradle` checks managed Nether quartz, tag and + explicit hosts, new-terrain edits and shutdown cleanup in a packaged server. +- `release/artifacts.gradle` checks the three release jars, bundled guide, + metadata and checksums. +- `release/publishing.gradle` holds the opt-in Maven publication and credential + checks. An ordinary build never uploads anything. +- `ide/eclipse.gradle` keeps Buildship on processed production resources and + checks that test fixtures do not enter normal Eclipse launches. + +Run `clean` separately, then `check build javadoc verifyReleaseArtifacts +writeReleaseChecksums`. After changing resources or launch settings, run +`eclipse verifyEclipseProductionClasspath`. Buildship supplies NeoGradle's +Eclipse import marker automatically; from a terminal, also pass +`-Declipse.application=org.eclipse.jdt.ls.core.id1` (quote this argument in +PowerShell). This regenerates the native launches rather than validating stale +files from an earlier import. +The client, future-provider, biome-replacement, packaged-runtime and performance +checks are separate opt-in gates; their required runtime paths are described +beside those tasks. Use Java 25 and +the branch's configured Gradle cache for NeoForge 26.3. + +`nativeOreIntegrationTest` requires +`-PpackagedNeoForgeServerRuntime=`. To test a +different compatible loader, also pass `-PpackagedNeoForgeVersion=`. +Every packaged server command ends in literal `nogui`. + +`oreSourcesClientIntegrationProcess` opens the editors and a fresh/reloaded +world with the two future-provider fixtures. For manual testing, use +`runOreSourcesClient.launch` or `runTestOreSourcesClient.launch`; both use +`run-ore-sources`, leaving the ordinary profile alone. Only fixture jars are +refreshed there; configs and saves are not replaced. + +Both isolated client checks inspect Minecraft's queued text as well as opening +the screens. They cover the biome directory and compact detail page at +426x265 and 320x240, so a working button cannot hide an invisible list label. +They also send native mouse events through the screen to check biome selection, +scrolling and dragging, compact navigation, and the shared-ore output checkboxes. +Main-menu checks measure the native tab and fallback screen against their +footer boundaries, with terrain, fluid and template controls on and off. + +When adding a task, put it next to the checks it supports and keep any shared +helper in the small `oreSpawnBuild` map in the root build. Test-only source sets +must stay out of the release jars and normal Eclipse classpath. diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle new file mode 100644 index 00000000..694fcee5 --- /dev/null +++ b/gradle/ide/eclipse.gradle @@ -0,0 +1,218 @@ +import groovy.xml.XmlSlurper + +// Keep ordinary Eclipse launches production-only and use processed resources. +def prepareEclipseResources = project.ext.oreSpawnBuild.prepareEclipseResources +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +eclipse { + project { + natures 'org.eclipse.buildship.core.gradleprojectnature' + buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder' + } + classpath { + downloadSources = true + downloadJavadoc = true + defaultOutputDir = file('bin/default') + file { + whenMerged { classpath -> + def libraryPaths = new LinkedHashSet() + classpath.entries = classpath.entries.findAll { entry -> + !(entry instanceof org.gradle.plugins.ide.eclipse.model.Library) + || libraryPaths.add(entry.path) + } + classpath.entries.findAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.Output + }.each { entry -> + entry.path = 'bin/default' + } + classpath.entries.removeAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + && ['src/main/resources', 'src/generated/resources'].contains(entry.path) + } + if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + 'build/resources/main', 'bin/main')) + } + classpath.entries.findAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + }.each { entry -> + if (entry.path == 'build/resources/main' || entry.path == 'src/main/java') { + entry.output = 'bin/main' + } else if (entry.path.startsWith('src/test/')) { + entry.output = 'bin/test' + } + } + } + } + } + synchronizationTasks 'prepareEclipseResources' +} + +tasks.named('eclipse').configure { + dependsOn prepareEclipseResources + dependsOn tasks.named('prepareOreSourcesClient') + doLast { + String requestedGradleHome = System.getenv('GRADLE_USER_HOME') + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + if (preferencesFile.isFile()) { + preferencesFile.withInputStream { preferences.load(it) } + } + String existingGradleHome = preferences.getProperty('connection.gradle.user.home', '').trim() + File gradleHome = requestedGradleHome != null && !requestedGradleHome.trim().isEmpty() + ? file(requestedGradleHome) + : (!existingGradleHome.isEmpty() ? file(existingGradleHome) : gradle.gradleUserHomeDir) + preferences.setProperty('eclipse.preferences.version', '1') + preferences.setProperty('connection.gradle.distribution', 'GRADLE_DISTRIBUTION(WRAPPER)') + preferences.setProperty('override.workspace.settings', 'true') + preferences.setProperty('gradle.user.home', gradleHome.canonicalPath) + preferences.setProperty('connection.gradle.user.home', gradleHome.canonicalPath) + preferences.setProperty('java.home', System.getProperty('java.home')) + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, 'Generated by the OreSpawn Eclipse task to keep NeoGradle launch modules on one cache.') + } + + // NeoGradle puts its launches here, not beside build.gradle. + File launches = file('.eclipse/configurations') + fileTree(launches) { include 'Run *.launch' }.files.each { launch -> + String contents = launch.getText('UTF-8') + String attribute = 'org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE' + if (!contents.contains(attribute)) { + contents = contents.replace('', + ' \n') + } + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + File manual = new File(launches, 'Run OreSpawn - OreSourcesClient.launch') + if (!manual.isFile()) throw new GradleException('Missing generated shared-ore client launch') + file('runOreSourcesClient.launch').setText(manual.getText('UTF-8'), 'UTF-8') + file('runTestOreSourcesClient.launch').setText(manual.getText('UTF-8'), 'UTF-8') + } +} + +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipse') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties preferences = new Properties() + prefs.withInputStream { preferences.load(it) } + if (preferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException('Eclipse Buildship workspace override is not active') + } + String eclipseJavaHome = preferences.getProperty('java.home', '').trim() + if (eclipseJavaHome.isEmpty() || !new File(eclipseJavaHome, 'release').isFile()) { + throw new GradleException("Eclipse Buildship Java home is missing or invalid: ${eclipseJavaHome}") + } + String eclipseJavaRelease = new File(eclipseJavaHome, 'release').getText('UTF-8') + if (!eclipseJavaRelease.contains('JAVA_VERSION="25.0.3"') + || !eclipseJavaRelease.contains('IMPLEMENTOR="Eclipse Adoptium"')) { + throw new GradleException( + "Eclipse Buildship must use exact Temurin 25.0.3+9: ${eclipseJavaHome}") + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) throw new GradleException('Eclipse .classpath was not generated') + String classpathText = eclipseClasspath.getText('UTF-8') + if (!classpathText.contains('path="build/resources/main"') + || classpathText.contains('path="src/main/resources"') + || classpathText.contains('path="src/generated/resources"')) { + throw new GradleException('Eclipse must consume only Gradle-processed production resources') + } + def classpathXml = new XmlSlurper(false, false).parse(eclipseClasspath) + String defaultOutput = classpathXml.classpathentry + .find { it.@kind.text() == 'output' }.@path.text() + Map expectedSourceOutputs = [ + 'src/main/java' : 'bin/main', + 'build/resources/main' : 'bin/main', + 'src/test/java' : 'bin/test', + 'src/test/resources' : 'bin/test' + ] + List invalidSourceOutputs = classpathXml.classpathentry + .findAll { it.@kind.text() == 'src' } + .collect { [it.@path.text(), it.@output.text()] } + .findAll { pair -> + String expected = expectedSourceOutputs[pair[0]] + expected != null && pair[1] != expected + } + .collect { pair -> "${pair[0]} -> ${pair[1]}" } + boolean nestedOutput = classpathXml.classpathentry + .findAll { it.@kind.text() == 'src' && !it.@output.text().isEmpty() } + .any { entry -> + String output = entry.@output.text() + output == defaultOutput || output.startsWith(defaultOutput + '/') + } + if (defaultOutput != 'bin/default' || !invalidSourceOutputs.isEmpty() + || nestedOutput || classpathText.contains('output="build/sourceSets')) { + throw new GradleException( + "Eclipse outputs must be disjoint bin/default, bin/main and bin/test " + + "directories; default=${defaultOutput}, invalid=${invalidSourceOutputs}") + } + ['META-INF/neoforge.mods.toml', 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md'].each { relative -> + if (!new File('bin/main', relative).isFile()) { + throw new GradleException("Eclipse output is missing ${relative}") + } + } + File metadata = file('bin/main/META-INF/neoforge.mods.toml') + String metadataText = metadata.getText('UTF-8') + if (metadataText.contains('\${mod_version}') + || !metadataText.contains("version=\"${project.version}\"")) { + throw new GradleException('Eclipse output contains unexpanded NeoForge metadata') + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', 'benchmarkIntegrationTest', + 'nativeOreIntegrationTest', 'oreSourceMineralsTest', 'oreSourceAdvantageTest', + 'junit-', 'opentest4j-', + 'Mineralogy-1.18.2-5.4.0.jar', 'net.minecraftforge', 'META-INF/mods.toml', + 'C:\\Users\\John' + ] + Set launches = fileTree('.eclipse/configurations') { include 'Run *.launch' }.files + if (launches.isEmpty()) throw new GradleException('NeoGradle did not generate Eclipse launches') + launches.addAll(fileTree(project.projectDir) { include 'run*.launch' }.files) + launches.each { launch -> + List leaked = forbidden.findAll { launch.getText('UTF-8').contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${launch.name} exposes test/Forge/local content: ${leaked}") + } + if (launch.name in ['Run OreSpawn - Client.launch', 'Run OreSpawn - Server.launch']) { + List probeFlags = ['surfaceprobe', 'clientprobe', 'benchmarkprobe', 'oresourceprobe', 'nativeoreprobe'] + if (probeFlags.any { launch.getText('UTF-8').contains(it) }) { + throw new GradleException("${launch.name} enables a test probe in an ordinary run") + } + } + def xml = new XmlSlurper(false, false).parse(launch) + if (xml.booleanAttribute.find { + it.@key.text() == 'org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE' + }.@value.text() != 'true') { + throw new GradleException("${launch.name} must exclude Eclipse test code") + } + String modClasses = xml.mapAttribute.mapEntry.find { it.@key.text() == 'MOD_CLASSES' }.@value.text() + if (modClasses != project.name + '%%' + file('bin/main').absolutePath) { + throw new GradleException("${launch.name} has an unexpected production mod root: ${modClasses}") + } + } + def manual = new XmlSlurper(false, false).parse(file('runTestOreSourcesClient.launch')) + String directory = manual.stringAttribute.find { + it.@key.text() == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value.text() + if (file(directory).canonicalFile != file('run-ore-sources').canonicalFile) { + throw new GradleException('The shared-ore launch must use its separate manual profile') + } + def ordinary = new XmlSlurper(false, false).parse(file('.eclipse/configurations/Run OreSpawn - Client.launch')) + String ordinaryDirectory = ordinary.stringAttribute.find { + it.@key.text() == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value.text() + if (file(ordinaryDirectory).canonicalFile != file('run').canonicalFile) { + throw new GradleException('The ordinary client must keep its own run directory') + } + } +} diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle new file mode 100644 index 00000000..f7a9bfa5 --- /dev/null +++ b/gradle/release/artifacts.gradle @@ -0,0 +1,279 @@ +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile + +// Release files, documentation parity and checksum checks. +def expectedMavenGroup = project.ext.oreSpawnBuild.expectedMavenGroup +def expectedMavenArtifact = project.ext.oreSpawnBuild.expectedMavenArtifact +def expectedMavenCoordinate = project.ext.oreSpawnBuild.expectedMavenCoordinate +def archiveTextSuffixes = project.ext.oreSpawnBuild.archiveTextSuffixes +def bundledDocumentationDirectory = project.ext.oreSpawnBuild.bundledDocumentationDirectory +def prepareBundledDocumentation = project.ext.oreSpawnBuild.prepareBundledDocumentation +def releaseJar = tasks.named('jar', Jar) +def expectedReleaseFiles = providers.provider { + String prefix = "${base.archivesName.get()}-${project.version}" + ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + if (project.mod_version != '4.1.0.2603002' + || project.mod_group_id != expectedMavenGroup + || project.minecraft_version != '26.3' + || project.neo_version != '26.3.0.1-beta') { + throw new GradleException('Unexpected OreSpawn NeoForge 26.3 release identity') + } + if (project.loader_name != 'neoforge' || project.loader_code != '2' + || project.java_version != '25' || project.gradle_java_version != '25' + || project.java_toolchain_version != '25.0.3+9' + || project.java_setup_version != '25.0.3+9.0.LTS') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.1.0.2603002.jar', + 'OreSpawn-4.1.0.2603002-sources.jar', + 'OreSpawn-4.1.0.2603002-javadoc.jar' + ] + if (base.archivesName.get() != expectedMavenArtifact + || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { + throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') + } + String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') + expectedPublicArtifacts.each { artifactName -> + if (!ciWorkflow.contains("build/libs/${artifactName}")) { + throw new GradleException("CI does not upload expected public artifact ${artifactName}") + } + } + [ + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains(project.mod_version)) { + throw new GradleException("Release identity missing from ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + } +} + +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + if (!java.util.Arrays.equals(new File(trackedDocumentationDirectory, relative).bytes, + new File(root, relative).bytes)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def prepareEclipseResources = tasks.register('prepareEclipseResources', Sync) { + dependsOn tasks.named('processResources') + from layout.buildDirectory.dir('resources/main') + into file('bin/main') + // Resource refreshes must not delete classes already compiled by Eclipse. + preserve { include '**/*.class' } +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 20 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 20 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, expected, 'generated resources') + assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, + 'META-INF/orespawn/docs'), expected, 'processed resources') + assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), expected, 'Eclipse bin/main') + new ZipFile(releaseJar.get().archiveFile.get().asFile).withCloseable { zip -> + expected.each { relative -> + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals( + new File(trackedDocumentationDirectory, relative).bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Release jar documentation differs at ${relative}") + } + } + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + dependsOn tasks.named('assemble') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } + } + if (cr) throw new GradleException("${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'src/benchmarkIntegrationTest/', 'agent-notes/', 'surfaceprobe', + 'clientprobe', 'benchmarkprobe', 'ci-fixtures/', 'net/minecraftforge/', + 'META-INF/mods.toml', 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.18.2-5.4.0.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException("${candidate.name} contains forbidden ${forbidden}") + } + } + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/neoforge.mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json' + ].each { required -> + if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}") + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/neoforge.mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[26.3.0.1-beta,26.4)"')) { + throw new GradleException('Packaged NeoForge metadata is incorrect') + } + String transformer = zip.getInputStream(zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List actualRules = transformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() }.findAll { !it.isEmpty() } + List expectedRules = [ + 'public-f net.minecraft.world.level.chunk.ChunkGenerator biomeSource', + 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator globalFluidPicker', + 'public-f net.minecraft.world.level.biome.Biome generationSettings', + 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabManager', + 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabNavigationBar' + ] + if (actualRules != expectedRules) { + throw new GradleException("Unexpected packaged official-named access transformer: ${actualRules}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry == null ? null : + new Manifest(zip.getInputStream(manifestEntry)).mainAttributes + if (manifest == null + || manifest.getValue('Implementation-Version') != project.mod_version + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('OreSpawn-Loader') != 'neoforge' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null) { + throw new GradleException('Release manifest is incorrect or volatile') + } + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = zip.getInputStream(entry).withCloseable { it.readNBytes(8) } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 69) throw new GradleException("${entry.name} uses class major ${major}, expected 69") + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> + if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> + if (zip.getEntry('index.html') == null + || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { + throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') + } + } + } +} + +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + inputs.files(expectedReleaseFiles.map { names -> + names.collect { layout.buildDirectory.file("libs/${it}").get().asFile } + }) + outputs.file outputFile + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) throw new GradleException('preparedReleaseDir is required') + File prepared = file(preparedReleaseDir.get()) + List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + if (jars.collect { it.name } != expected || jars.any { it.length() == 0L }) { + throw new GradleException("Prepared release jars ${jars*.name} do not match ${expected}") + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + } +} + +project.ext.oreSpawnBuild.expectedReleaseFiles = expectedReleaseFiles +project.ext.oreSpawnBuild.preparedReleaseDir = preparedReleaseDir +project.ext.oreSpawnBuild.prepareEclipseResources = prepareEclipseResources diff --git a/gradle/release/publishing.gradle b/gradle/release/publishing.gradle new file mode 100644 index 00000000..36741706 --- /dev/null +++ b/gradle/release/publishing.gradle @@ -0,0 +1,85 @@ +import groovy.xml.XmlSlurper + +// Publication is opt-in; an ordinary build never uploads artifacts. +def expectedMavenGroup = project.ext.oreSpawnBuild.expectedMavenGroup +def expectedMavenArtifact = project.ext.oreSpawnBuild.expectedMavenArtifact +def expectedReleaseFiles = project.ext.oreSpawnBuild.expectedReleaseFiles +def preparedReleaseDir = project.ext.oreSpawnBuild.preparedReleaseDir +def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') + .orElse('https://invalid.invalid/missing-maven-upload-url') +def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') +def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') +publishing { + publications { + mavenJava(MavenPublication) { + groupId = expectedMavenGroup + artifactId = expectedMavenArtifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + File prepared = file(preparedReleaseDir.get()) + artifact(new File(prepared, expectedReleaseFiles.get()[0])) + artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } + artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } + } else { + from components.java + } + pom { + name = 'MMD OreSpawn' + description = project.mod_description + url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' + licenses { + license { + name = 'GNU Lesser General Public License, Version 2.1' + url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' + } + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} + +tasks.register('verifyMavenCoordinates') { + group = 'verification' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, project.version.toString()] + if (project.group.toString() != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, found ${actual.join(':')}") + } + } +} + +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { + throw new GradleException('Release publication must use a remote repository') + } + } +} +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn preparedReleaseDir.isPresent() + ? tasks.named('verifyPreparedReleaseArtifacts') + : tasks.named('verifyReleaseArtifacts') +} diff --git a/gradle/verification/biome-override.gradle b/gradle/verification/biome-override.gradle new file mode 100644 index 00000000..cfa07a18 --- /dev/null +++ b/gradle/verification/biome-override.gradle @@ -0,0 +1,100 @@ +// Generate an old chunk, save a replacement, then verify only new terrain changes. +def shared = project.ext.oreSpawnBuild +def hashClasses = layout.buildDirectory.dir('world-hash-fixture/classes') +def compileWorldHashProbe = tasks.register('compileWorldHashProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/worldHashTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(hashClasses) + javaCompiler.set(javaToolchains.compilerFor { languageVersion = JavaLanguageVersion.of(25) }) + options.release = 25 + options.encoding = 'UTF-8' +} +def worldHashProbeJar = tasks.register('worldHashProbeJar', Jar) { + dependsOn compileWorldHashProbe + archiveFileName = 'worldhashprobe.jar' + destinationDirectory = layout.buildDirectory.dir('world-hash-fixture') + from hashClasses + from 'src/worldHashTest/resources' +} +File runDir = layout.buildDirectory.dir('biome-override-run').get().asFile +File worldDir = new File(runDir, 'biome-override-world') +File markerFile = new File(worldDir, 'biomeoverrideprobe.properties') +def prepare = tasks.register('prepareBiomeOverrideProbe') { + dependsOn worldHashProbeJar + doLast { + delete runDir + runDir.mkdirs() + shared.prepareTestNeoForgeConfig(runDir) + copy { + from worldHashProbeJar.flatMap { it.archiveFile } + into new File(runDir, 'mods') + } + new File(runDir, 'server.properties').setText('''\ +level-name=biome-override-world +level-seed=49110 +level-type=minecraft:normal +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDir, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def writeOverride = tasks.register('writeBiomeOverride') { + dependsOn 'runBiomeOverrideFresh' + doLast { + if (!markerFile.isFile()) throw new GradleException("Missing fresh biome marker: ${markerFile}") + Properties marker = new Properties() + markerFile.withInputStream { marker.load(it) } + String source = marker.getProperty('source') + if (source == null || !source.contains(':')) { + throw new GradleException("Missing source biome in ${markerFile}") + } + String target = source == 'minecraft:desert' ? 'minecraft:plains' : 'minecraft:desert' + File profileFile = new File(worldDir, 'serverconfig/orespawn-worldgen.json') + if (!profileFile.isFile()) throw new GradleException("Missing world profile: ${profileFile}") + Map profile = (Map) new groovy.json.JsonSlurper().parse(profileFile) + Map palettes = profile.biome_palettes instanceof Map ? (Map) profile.biome_palettes + : new LinkedHashMap() + profile.biome_palettes = palettes + palettes['orespawn:ui/biome_overrides/minecraft_overworld'] = [ + dimension: 'minecraft:overworld', enabled: true, mode: 'replace', + scope: 'all', region_size: 'tiny', coverage: 1.0D, + fallback_weight: 0.0D, + biomes: [(target): [enabled: true, weight: 1.0D, + similar_biomes: [source]]] + ] + profileFile.setText(groovy.json.JsonOutput.prettyPrint( + groovy.json.JsonOutput.toJson(profile)) + '\n', 'UTF-8') + marker.setProperty('target', target) + markerFile.withOutputStream { marker.store(it, 'Disposable biome override probe') } + } +} + +tasks.configureEach { + if (name == 'runBiomeOverrideFresh') dependsOn prepare + if (name == 'runBiomeOverrideReload') dependsOn writeOverride + if (name == 'runBiomeOverrideFresh' || name == 'runBiomeOverrideReload') { + doFirst { ext.biomeOverridePriorCrashes = shared.runtimeCrashSnapshot(workingDir) } + doLast { + shared.assertRuntimeLogsClean(workingDir, name, ext.biomeOverridePriorCrashes as Set) + } + } +} +tasks.register('biomeOverrideIntegrationTest') { + group = 'verification' + description = 'Proves exact replacement changes new chunks without rewriting saved chunks.' + dependsOn 'runBiomeOverrideReload' + doLast { + Properties marker = new Properties() + markerFile.withInputStream { marker.load(it) } + if (marker.getProperty('reload_verified') != 'true') { + throw new GradleException('Biome replacement did not pass fresh/reload verification') + } + } +} diff --git a/gradle/verification/client-and-benchmark.gradle b/gradle/verification/client-and-benchmark.gradle new file mode 100644 index 00000000..eee8a58e --- /dev/null +++ b/gradle/verification/client-and-benchmark.gradle @@ -0,0 +1,143 @@ +// Fixture clients and generated worlds never enter the production source set. +def shared = project.ext.oreSpawnBuild +def runtimeCrashSnapshot = shared.runtimeCrashSnapshot +def assertRuntimeLogsClean = shared.assertRuntimeLogsClean +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(surfaceIntegrationClasses) + javaCompiler.set(javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(25) + }) + options.release = 25 + options.encoding = 'UTF-8' +} + +def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { + dependsOn compileSurfaceIntegrationTestMod + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') + from surfaceIntegrationClasses + from 'src/biomeIntegrationTest/resources' +} + +def surfaceIntegrationRunDirectory = layout.buildDirectory.dir('surface-integration-run') +def prepareSurfaceIntegrationTest = tasks.register('prepareSurfaceIntegrationTest') { + dependsOn surfaceIntegrationTestModJar + doLast { + delete surfaceIntegrationRunDirectory + File runDirectory = surfaceIntegrationRunDirectory.get().asFile + runDirectory.mkdirs() + shared.prepareTestNeoForgeConfig(runDirectory) + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'server.properties').setText( + 'level-seed=0\nonline-mode=false\nserver-port=0\nspawn-protection=0\nsync-chunk-writes=false\n', + 'UTF-8') + copy { + from surfaceIntegrationTestModJar.flatMap { it.archiveFile } + into surfaceIntegrationRunDirectory.map { it.dir('mods') } + } + } +} + +tasks.configureEach { + 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.3 ${name}", + ext.oreSpawnCrashSnapshot as Set) + } + } +} + +def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { + group = 'verification' + description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' + dependsOn 'runSurfaceIntegrationReload' + doLast { + File marker = surfaceIntegrationRunDirectory.get().file( + 'world/surfaceprobe-integration.properties').asFile + if (!marker.isFile()) { + 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("Surface integration reload was not verified: ${marker}") + } + 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 surfaceIntegrationTest +} + +def clientIntegrationClasses = layout.buildDirectory.dir('client-integration-fixture/classes') +def compileClientIntegrationTestMod = tasks.register('compileClientIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(clientIntegrationClasses) + options.release = 25 + options.encoding = 'UTF-8' +} +def clientIntegrationTestModJar = tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn compileClientIntegrationTestMod + archiveFileName = 'clientprobe.jar' + destinationDirectory = layout.buildDirectory.dir('client-integration-fixture') + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} + + +shared.clientIntegrationTestModJar = clientIntegrationTestModJar +shared.surfaceIntegrationTestModJar = surfaceIntegrationTestModJar + +def clientIntegrationRunDirectory = layout.buildDirectory.dir('client-integration-run').get().asFile +def prepareClientIntegration = tasks.register('prepareClientIntegration') { + dependsOn clientIntegrationTestModJar + doLast { + String buildRoot = layout.buildDirectory.get().asFile.canonicalPath + File.separator + if (!clientIntegrationRunDirectory.canonicalPath.startsWith(buildRoot)) { + throw new GradleException('Client probe directory is outside build output') + } + delete clientIntegrationRunDirectory + new File(clientIntegrationRunDirectory, 'mods').mkdirs() + shared.prepareTestNeoForgeConfig(clientIntegrationRunDirectory) + copy { + from clientIntegrationTestModJar.flatMap { it.archiveFile } + into new File(clientIntegrationRunDirectory, 'mods') + } + new File(clientIntegrationRunDirectory, 'options.txt').setText('fullscreen:false\nguiScale:2\n', 'UTF-8') + } +} +tasks.matching { it.name == 'runClient41Integration' }.configureEach { + dependsOn prepareClientIntegration +} +tasks.register('clientIntegrationProcess') { + group = 'verification' + description = 'Opens the 4.1 editors and renders a fresh and reloaded world in an isolated client.' + dependsOn 'runClient41Integration' + doLast { + File marker = new File(clientIntegrationRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) throw new GradleException('The client UI probe did not finish') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + for (String key : ['ore_sources_opened', 'mods_opened', 'editor_text_verified', 'biome_input_verified', 'main_menu_layout_verified', 'first_world_rendered', 'reload_rendered']) { + if (result.getProperty(key) != 'true') throw new GradleException("Client UI probe missed ${key}") + } + shared.assertRuntimeLogsClean(clientIntegrationRunDirectory, + 'NeoForge 26.3 isolated client', [] as Set) + } +} diff --git a/gradle/verification/contracts.gradle b/gradle/verification/contracts.gradle new file mode 100644 index 00000000..0385167a --- /dev/null +++ b/gradle/verification/contracts.gradle @@ -0,0 +1,69 @@ +// Portable commands and cold-bootstrap assumptions. +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific wrappers in generic Gradle task execution.' + doLast { + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + } +} + +tasks.register('verifyColdNeoForgeBootstrapContract') { + group = 'verification' + description = 'Verifies that CI qualifies NeoGradle from an isolated Java 25 cache without Forge tooling or forced source decompilation.' + doLast { + Properties gradleProperties = new Properties() + file('gradle.properties').withInputStream { gradleProperties.load(it) } + if (gradleProperties.getProperty('neogradle.subsystems.decompiler.enabled') != null) { + throw new GradleException('NeoForge 26.3 must retain its validated binary userdev path') + } + + String workflow = file('.github/workflows/ci.yml').getText('UTF-8') + [ + 'Cold NeoForge bootstrap', + 'orespawn-neoforge-cold-cache', + '--no-build-cache', + '--offline', + './gradlew classes verifyLegacyFixtures --no-daemon', + './gradlew classes verifyLegacyFixtures --offline --no-daemon', + './gradlew check build javadoc', + "java-version: '25.0.3+9.0.LTS'" + ].each { required -> + if (!workflow.contains(required)) { + throw new GradleException("Cold NeoForge bootstrap is missing ${required}") + } + } + ['MinecraftMavenizer', 'mavenizer', 'net.minecraftforge.gradle', + 'neogradle.subsystems.decompiler.enabled=true', + "java-version: '21", "java-version: '17", "java-version: '8"].each { forbidden -> + if (workflow.contains(forbidden)) { + throw new GradleException("Cold NeoForge bootstrap contains Forge-only tooling: ${forbidden}") + } + } + ['./gradlew clean check', './gradlew clean classes'].each { combined -> + if (workflow.contains(combined)) { + throw new GradleException( + "NeoGradle clean must run separately from model consumers: ${combined}") + } + } + + String codeQlWorkflow = file('.github/workflows/codeql-analysis.yml').getText('UTF-8') + if (!codeQlWorkflow.contains('./gradlew clean --no-daemon') + || codeQlWorkflow.contains('clean classes')) { + throw new GradleException( + 'CodeQL must clean once before its cache-preserving compilation retry loop') + } + } +} + +tasks.named('check') { + dependsOn tasks.named('verifyCommandPortability') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn tasks.named('verifyColdNeoForgeBootstrapContract') +} diff --git a/gradle/verification/native-ores.gradle b/gradle/verification/native-ores.gradle new file mode 100644 index 00000000..7a5b9ebb --- /dev/null +++ b/gradle/verification/native-ores.gradle @@ -0,0 +1,105 @@ +// Check real Nether terrain with the ordinary jar; the probe is test-only. +def shared = project.ext.oreSpawnBuild +def classes = layout.buildDirectory.dir('native-ore-fixture/classes') +def compile = tasks.register('compileNativeOreProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/nativeOreIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = classes + options.release = 25 + options.encoding = 'UTF-8' +} +def probeJar = tasks.register('nativeOreProbeJar', Jar) { + dependsOn compile + archiveFileName = 'nativeoreprobe.jar' + destinationDirectory = layout.buildDirectory.dir('native-ore-fixture') + from classes + from 'src/nativeOreIntegrationTest/resources' +} +def runtime = providers.gradleProperty('packagedNeoForgeServerRuntime') +def comparisonJar = providers.gradleProperty('nativeOreSpawnJar') +def finalTasks = [] +['Default', 'Explicit'].each { scenario -> + File runDirectory = layout.buildDirectory.dir("native-ore-${scenario.toLowerCase(Locale.ROOT)}-run").get().asFile + def prepare = tasks.register("prepareNativeOre${scenario}") { + dependsOn tasks.named('jar'), probeJar + doLast { + delete runDirectory + runDirectory.mkdirs() + shared.prepareTestNeoForgeConfig(runDirectory) + copy { + from comparisonJar.isPresent() ? file(comparisonJar.get()) : tasks.named('jar').flatMap { it.archiveFile } + from probeJar.flatMap { it.archiveFile } + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'server.properties').setText('''\ +level-name=native-ore-world +level-seed=4192643144957839458 +online-mode=false +server-port=0 +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + } + } + def previous = prepare + ['Fresh', 'Reload', 'Edited'].each { phase -> + def dependency = previous + previous = tasks.register("nativeOre${scenario}${phase}", Exec) { + group = 'verification' + description = "Checks managed quartz with ${scenario.toLowerCase(Locale.ROOT)} hosts (${phase.toLowerCase(Locale.ROOT)})." + dependsOn dependency + doFirst { + if (!runtime.isPresent()) throw new GradleException('Pass -PpackagedNeoForgeServerRuntime=') + workingDir runDirectory + // One generation worker makes independent host-form runs comparable. + commandLine shared.neoForgeServerCommand(file(runtime.get()), runDirectory, + ['-XX:ActiveProcessorCount=2', '-Dio.netty.transport.noNative=true', + "-Dnativeoreprobe.phase=${phase.toLowerCase(Locale.ROOT)}", + "-Dnativeoreprobe.host=${scenario.toLowerCase(Locale.ROOT)}"]) + } + doLast { + File marker = new File(runDirectory, 'native-ore-world/nativeoreprobe.properties') + Properties result = new Properties() + if (!marker.isFile()) throw new GradleException("Missing quartz audit: ${marker}") + marker.withInputStream { result.load(it) } + if (result.getProperty("${phase.toLowerCase(Locale.ROOT)}_verified") != 'true') { + throw new GradleException("Quartz ${phase.toLowerCase(Locale.ROOT)} was not verified") + } + shared.assertRuntimeLogsClean(runDirectory, "Native quartz ${scenario} ${phase}", [] as Set) + copy { + from new File(runDirectory, 'logs/latest.log') + into new File(runDirectory, 'audit-logs') + rename { "${phase.toLowerCase(Locale.ROOT)}.log" } + } + copy { + from marker + into new File(runDirectory, 'audit-logs') + rename { "${phase.toLowerCase(Locale.ROOT)}.properties" } + } + } + } + } + finalTasks.add(previous) +} +tasks.register('nativeOreIntegrationTest') { + group = 'verification' + description = 'Checks quartz tag/explicit hosts, exact-save reload and new-terrain-only edits.' + dependsOn finalTasks + doLast { + ['fresh', 'reload', 'edited'].each { phase -> + Properties tagged = new Properties() + Properties explicit = new Properties() + file("${buildDir}/native-ore-default-run/audit-logs/${phase}.properties").withInputStream { tagged.load(it) } + file("${buildDir}/native-ore-explicit-run/audit-logs/${phase}.properties").withInputStream { explicit.load(it) } + ['quartz', 'blocks_sha256', 'biomes_sha256', 'new_terrain_quartz', 'water', 'new_terrain_water'].each { key -> + if (tagged.getProperty(key) != explicit.getProperty(key)) { + throw new GradleException("Tag and explicit quartz hosts differ for ${phase}: ${key}") + } + } + } + logger.lifecycle('Tag and explicit quartz hosts produce identical terrain, fresh/reload/edited') + } +} diff --git a/gradle/verification/ore-sources.gradle b/gradle/verification/ore-sources.gradle new file mode 100644 index 00000000..d52b4dfa --- /dev/null +++ b/gradle/verification/ore-sources.gradle @@ -0,0 +1,161 @@ +// Future MMD providers are ordinary test-only mods, never production dependencies. +def oreSourceFixtures = [:] +['Minerals': 'oreSourceMineralsTest', 'Advantage': 'oreSourceAdvantageTest'].each { label, folder -> + def classesDir = layout.buildDirectory.dir("ore-source-fixture/${label.toLowerCase(Locale.ROOT)}-classes") + def compile = tasks.register("compileOreSource${label}Fixture", JavaCompile) { + dependsOn tasks.named('classes') + source fileTree("src/${folder}/java") + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(classesDir) + javaCompiler.set(javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(25) + }) + options.release = 25 + options.encoding = 'UTF-8' + } + oreSourceFixtures[label] = tasks.register("oreSource${label}FixtureJar", Jar) { + dependsOn compile + archiveFileName = "oresource-${label.toLowerCase(Locale.ROOT)}.jar" + destinationDirectory = layout.buildDirectory.dir('ore-source-fixture') + from classesDir + from "src/${folder}/resources" + } +} + +def oreSourceRuns = [:] +['One': ['Minerals'], 'Both': ['Minerals', 'Advantage']].each { scenario, labels -> + File runDir = layout.buildDirectory.dir( + "ore-source-${scenario.toLowerCase(Locale.ROOT)}-run").get().asFile + def prepare = tasks.register("prepareOreSource${scenario}") { + labels.each { dependsOn oreSourceFixtures[it] } + doLast { + delete runDir + runDir.mkdirs() + project.ext.oreSpawnBuild.prepareTestNeoForgeConfig(runDir) + copy { + labels.each { from oreSourceFixtures[it].flatMap { jar -> jar.archiveFile } } + into new File(runDir, 'mods') + } + new File(runDir, 'server.properties').setText('''\ +level-name=ore-source-world +level-seed=49110 +level-type=minecraft:normal +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDir, 'eula.txt').setText('eula=true\n', 'UTF-8') + } + } + String fresh = "runOreSource${scenario}Fresh" + String reload = "runOreSource${scenario}Reload" + tasks.configureEach { + if (name == fresh) dependsOn prepare + if (name == reload) dependsOn fresh + } + oreSourceRuns[scenario] = [runDir: runDir, reload: reload] + tasks.register("oreSource${scenario}IntegrationTest") { + group = 'verification' + description = "Checks ${scenario.toLowerCase(Locale.ROOT)} future ore-provider scenario on a fresh world and exact reload." + dependsOn reload + doLast { + File marker = new File(runDir, 'ore-source-world/oresourceprobe.properties') + if (!marker.isFile()) throw new GradleException("Ore-source marker is missing: ${marker}") + Properties results = new Properties() + marker.withInputStream { results.load(it) } + if (results.getProperty('reload_verified') != 'true') { + throw new GradleException("Ore-source reload was not verified: ${marker}") + } + } + } +} + +File bothRun = oreSourceRuns.Both.runDir +File advantageJar = new File(bothRun, 'mods/oresource-advantage.jar') +File parkedJar = new File(bothRun, 'oresource-advantage.jar') +tasks.configureEach { + if (name.startsWith('runOreSource')) { + doFirst { ext.oreSourcePriorCrashes = project.ext.oreSpawnBuild.runtimeCrashSnapshot(workingDir) } + doLast { + project.ext.oreSpawnBuild.assertRuntimeLogsClean(workingDir, name, + ext.oreSourcePriorCrashes as Set) + } + } + if (name == 'runOreSourceBothMissing') { + dependsOn oreSourceRuns.Both.reload + doFirst { java.nio.file.Files.move(advantageJar.toPath(), parkedJar.toPath()) } + } else if (name == 'runOreSourceBothRestored') { + dependsOn 'runOreSourceBothMissing' + doFirst { java.nio.file.Files.move(parkedJar.toPath(), advantageJar.toPath()) } + } +} +tasks.register('oreSourceFixtureIntegrationTest') { + group = 'verification' + description = 'Checks one and two future providers, then removes and restores one on the same save.' + dependsOn tasks.named('oreSourceOneIntegrationTest'), 'runOreSourceBothRestored' + doLast { + Properties results = new Properties() + new File(bothRun, 'ore-source-world/oresourceprobe.properties').withInputStream { + results.load(it) + } + if (results.getProperty('restored_verified') != 'true') { + throw new GradleException('Restored provider was not verified') + } + } +} + +// Never replace a manual profile's configs or saves when rebuilding the fixture jars. +tasks.register('prepareOreSourcesClient') { + group = 'verification' + description = 'Installs two test-only future providers in the separate manual client profile.' + dependsOn oreSourceFixtures.Minerals, oreSourceFixtures.Advantage + doLast { + copy { + from oreSourceFixtures.Minerals.flatMap { it.archiveFile } + from oreSourceFixtures.Advantage.flatMap { it.archiveFile } + into file('run-ore-sources/mods') + } + } +} +tasks.matching { it.name == 'runOreSourcesClient' }.configureEach { + dependsOn tasks.named('prepareOreSourcesClient') +} + +File sharedClient = layout.buildDirectory.dir('client-ore-sources-run').get().asFile +tasks.register('prepareOreSourcesClientIntegration') { + dependsOn oreSourceFixtures.Minerals, oreSourceFixtures.Advantage, + project.ext.oreSpawnBuild.clientIntegrationTestModJar + doLast { + delete sharedClient + sharedClient.mkdirs() + project.ext.oreSpawnBuild.prepareTestNeoForgeConfig(sharedClient) + new File(sharedClient, 'options.txt').setText('fullscreen:false\nguiScale:2\n', 'UTF-8') + copy { + from oreSourceFixtures.Minerals.flatMap { it.archiveFile } + from oreSourceFixtures.Advantage.flatMap { it.archiveFile } + from project.ext.oreSpawnBuild.clientIntegrationTestModJar.flatMap { it.archiveFile } + into new File(sharedClient, 'mods') + } + } +} +tasks.matching { it.name == 'runOreSourcesClientIntegration' }.configureEach { + dependsOn tasks.named('prepareOreSourcesClientIntegration') +} +tasks.register('oreSourcesClientIntegrationProcess') { + group = 'verification' + description = 'Opens the shared-ore GUI with both future providers and renders a fresh and reloaded world.' + dependsOn 'runOreSourcesClientIntegration' + doLast { + File marker = new File(sharedClient, 'client-smoke-pass.properties') + Properties results = new Properties() + if (!marker.isFile()) throw new GradleException('Shared-ore client did not finish') + marker.withInputStream { results.load(it) } + ['ore_sources_opened', 'mods_opened', 'editor_text_verified', 'biome_input_verified', 'ore_source_input_verified', 'main_menu_layout_verified', 'first_world_rendered', 'reload_rendered'].each { key -> + if (results.getProperty(key) != 'true') throw new GradleException("Shared-ore client missed ${key}") + } + project.ext.oreSpawnBuild.assertRuntimeLogsClean(sharedClient, 'Shared-ore client', [] as Set) + } +} diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle new file mode 100644 index 00000000..448d20cd --- /dev/null +++ b/gradle/verification/support.gradle @@ -0,0 +1,255 @@ +// Test isolation and runtime log checks shared by the development gates. +def mineralogy5OracleJar = project.ext.oreSpawnBuild.mineralogy5OracleJar + +// Prepare only disposable runs. A manual profile's settings always belong to its user. +def prepareTestNeoForgeConfig = { File runDirectory -> + String buildRoot = layout.buildDirectory.get().asFile.canonicalPath + File.separator + if (!runDirectory.canonicalPath.startsWith(buildRoot)) { + throw new GradleException('Test configuration must stay inside the build directory') + } + File config = new File(runDirectory, 'config/fml.toml') + if (!config.exists()) { + config.parentFile.mkdirs() + config.setText(file('ci-fixtures/config/neoforge-26.3-fml.toml').getText('UTF-8'), 'UTF-8') + } +} + +// Use an installed production runtime, not the userdev classpath or a shell wrapper. +def neoForgeServerCommand = { File runtime, File runDirectory, List extraJvmArguments -> + String version = providers.gradleProperty('packagedNeoForgeVersion').getOrElse(neo_version) + String argumentName = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'win_args.txt' : 'unix_args.txt' + File installedArguments = new File(runtime, "libraries/net/neoforged/neoforge/${version}/${argumentName}") + if (!installedArguments.isFile()) throw new GradleException("Missing official NeoForge arguments: ${installedArguments}") + String libraryRoot = runtime.canonicalPath.replace('\\', '/') + '/libraries' + String arguments = installedArguments.getText('UTF-8') + .replace('libraries/', libraryRoot + '/') + .replace('-DlibraryDirectory=libraries', '-DlibraryDirectory=' + libraryRoot) + arguments = arguments.readLines().collect { line -> + line.contains(libraryRoot) && line.contains(' ') ? '"' + line.replace('"', '\\"') + '"' : line + }.join('\n') + '\n' + File argumentFile = new File(runDirectory, 'server.args') + argumentFile.setText(arguments, 'UTF-8') + File java = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(25) + }.get().executablePath.asFile + [java.absolutePath, '-Xmx3G'] + extraJvmArguments + ['@' + argumentFile.absolutePath, 'nogui'] +} + +project.ext.oreSpawnBuild.prepareTestNeoForgeConfig = prepareTestNeoForgeConfig +project.ext.oreSpawnBuild.neoForgeServerCommand = neoForgeServerCommand +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + dependsOn tasks.named('verifyLegacyFixtures') + doLast { + configurations.findAll { it.canBeResolved }.each { configuration -> + if (configuration.files.any { it.canonicalFile == mineralogy5OracleJar.canonicalFile }) { + throw new GradleException("Mineralogy oracle leaked into ${configuration.name}") + } + } + } +} + +// 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. +// NeoForge 26.3 and its plain official MDK produce this exact OSHI diagnostic +// on the current Windows host before mod loading. Keep the allowance scoped to +// the emitting class and complete message; every other ERROR/FATAL remains fatal. +def acceptedNeoForge263LogNoise = [ + ~/.*\[main\/ERROR\] \[oshi\.driver\.windows\.registry\.HkeyPerformanceDataUtil\/\]: Unable to locate English counter names in registry Perflib 009\. Counters may need to be rebuilt:\s*/ +] + +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 = acceptedNeoForge263LogNoise.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('NoSuchFieldError') || + line.contains('NoSuchMethodError') || + line.contains('AbstractMethodError') || + line.contains('NoClassDefFoundError') || + line.contains('ExceptionInInitializerError') || + line.contains('com.electronwill.nightconfig.core.io.ParsingException:') || + 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( + '[16Sept2026 08:00:15.959] [main/ERROR] [oshi.driver.windows.registry.HkeyPerformanceDataUtil/]: Unable to locate English counter names in registry Perflib 009. Counters may need to be rebuilt: \n', + 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-neoforge-263-windows-control-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') + new File(logs, 'latest.log').setText( + '[main/INFO] [neoforge]: java.lang.NoSuchFieldError: COMMON\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-renamed-config-field-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a missing config field') + new File(logs, 'latest.log').setText( + '[Server thread/INFO] [neoforge]: java.lang.AbstractMethodError: BiomeModifier.modify\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-biome-modifier-signature-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a missing biome modifier method') + new File(logs, 'latest.log').setText( + '[Thread-1/INFO] [STDERR]: com.electronwill.nightconfig.core.io.ParsingException: Invalid TOML data\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-config-parse-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a configuration parsing exception') + prepareTestNeoForgeConfig(probe) + File config = new File(probe, 'config/fml.toml') + Properties defaults = new Properties() + config.withInputStream { defaults.load(it) } + if (defaults.getProperty('earlyWindowControl') != 'true' + || defaults.getProperty('disableConfigWatcher') != 'false' + || defaults.getProperty('maxThreads') != '-1') { + throw new GradleException('Disposable FML configuration does not match the target defaults') + } + config.setText('manual sentinel\n', 'UTF-8') + prepareTestNeoForgeConfig(probe) + if (config.getText('UTF-8') != 'manual sentinel\n') { + throw new GradleException('Test preparation overwrote an existing configuration') + } + rejected = false + try { prepareTestNeoForgeConfig(projectDir) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Test preparation accepted a manual run directory') + 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() + if (taskName == 'runGameTestServer') { + File serverProperties = new File(runTask.workingDir, 'server.properties') + if (!serverProperties.isFile()) { + serverProperties.setText('# Generated for the OreSpawn GameTest gate\n', 'UTF-8') + } + } + runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) + } + doLast { + assertRuntimeLogsClean(runTask.workingDir, taskName, + runTask.ext.oreSpawnCrashSnapshot as Set) + } + } +} + +project.ext.oreSpawnBuild.runtimeCrashSnapshot = runtimeCrashSnapshot +project.ext.oreSpawnBuild.assertRuntimeLogsClean = assertRuntimeLogsClean diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java index 9b7e3626..3cb6df5e 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java @@ -15,13 +15,13 @@ import com.google.gson.JsonPrimitive; import net.minecraft.ChatFormatting; +import net.minecraft.core.registries.Registries; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.CycleButton; import net.minecraft.client.gui.components.TabButton; import net.minecraft.client.gui.components.events.GuiEventListener; -import net.minecraft.client.gui.components.tabs.MenuTabBar; import net.minecraft.client.gui.components.tabs.TabNavigationBar; import net.minecraft.client.gui.components.tabs.TabManager; import net.minecraft.client.gui.screens.AccessibilityOnboardingScreen; @@ -59,6 +59,11 @@ public final class ClientProbeTestMod { private int editorFrames; private boolean worldSettingsOpened; private boolean longEditorRoundTrip; + private boolean oreSourcesOpened; + private boolean modsOpened; + private int creationBiomeCount; + private int staticBiomeCount; + private int loadedBiomeCount; private List worldCreationButtons; private static final InputWithModifiers NO_MODIFIERS = new InputWithModifiers() { @Override public int input() { return 0; } @@ -81,7 +86,14 @@ public static void onScreenInitialized(ScreenEvent.Init.Post event) { public static void onScreenDrawn(ScreenEvent.Render.Post event) { ClientProbeTestMod probe = instance; if (probe != null && Boolean.getBoolean("clientprobe.enabled") - && isOreSpawnEditor(event.getScreen())) probe.editorFrames++; + && isOreSpawnEditor(event.getScreen())) { + try { + EditorTextRenderProbe.verify(event.getScreen(), event.getGuiGraphics()); + probe.editorFrames++; + } catch (ReflectiveOperationException | IllegalStateException exception) { + fail(Minecraft.getInstance(), "Editor text rendering failed: " + exception); + } + } } @SubscribeEvent @@ -110,6 +122,7 @@ private void handleClientTick() { minecraft.gui.screen().onClose(); } if (minecraft.gui.screen() instanceof TitleScreen) { + if (Boolean.getBoolean("clientprobe.sharedOres")) SharedOreClientProbe.verify(); Screen parent = minecraft.gui.screen(); CreateWorldScreen.openFresh(minecraft, () -> minecraft.gui.setScreen(parent)); nextState(1); @@ -124,18 +137,36 @@ && activateWorldSettings(minecraft.gui.screen(), worldCreationButtons)) { break; case 2: if (minecraft.gui.screen() instanceof CreateWorldScreen && stateTicks >= 2) { + CreateWorldScreen creation = (CreateWorldScreen) minecraft.gui.screen(); + creationBiomeCount = creation.getUiState().getSettings() + .worldgenLoadContext().lookupOrThrow(Registries.BIOME).size(); + // NeoForge exposes these biomes through the creation registry, not a static registry. + staticBiomeCount = 0; validateCaptions(minecraft.gui.screen()); validateLongEditorRoundTrip(minecraft, minecraft.gui.screen()); + MainMenuLayoutProbe.verify(creation); nextState(3); } break; case 3: if (minecraft.gui.screen() instanceof CreateWorldScreen) { CreateWorldScreen root = (CreateWorldScreen) minecraft.gui.screen(); + if (!modsOpened) { + Button mods = findButton(root, "Mods"); + if (mods != null) { + press(mods); + if (isScreen(minecraft.gui.screen(), "OreSpawnModsScreen")) { + modsOpened = true; + editorRoutes.add("OreSpawnModsScreen"); + nextState(4); + break; + } + } + } Button target = nextNavigationButton(root); if (target == null) { - if (editorRoutes.size() < 5) fail(minecraft, - "Only exercised " + editorRoutes.size() + " editor routes: " + editorRoutes); + if (editorRoutes.size() < 7 || !oreSourcesOpened || !modsOpened) fail(minecraft, + "Ore Sources, Mods or another editor route was unreachable: " + editorRoutes); root.getUiState().setSeed(WORLD_SEED); pressCreateWorld(root); nextState(6); @@ -151,7 +182,33 @@ && activateWorldSettings(minecraft.gui.screen(), worldCreationButtons)) { } break; case 4: + if (isScreen(minecraft.gui.screen(), "GeologyMaterialsScreen") && !oreSourcesOpened) { + Button sources = findButton(minecraft.gui.screen(), "Ore Sources"); + if (sources != null) { + editorFrames = 0; + press(sources); + break; + } + Button ores = findButton(minecraft.gui.screen(), "Ores"); + if (ores == null) fail(minecraft, "ORES tab is missing from the materials screen"); + press(ores); + break; + } + if (isScreen(minecraft.gui.screen(), "OreSourceListScreen")) { + if (editorFrames < 2) break; + EditorTextRenderProbe.exerciseOreSources(minecraft.gui.screen()); + if (Boolean.getBoolean("clientprobe.sharedOres") && !EditorTextRenderProbe.oreSourceInputVerified()) { + fail(minecraft, "Shared Ore Sources native input was not verified"); + } + validateCaptions(minecraft.gui.screen()); + editorRoutes.add("OreSourceListScreen"); + oreSourcesOpened = true; + minecraft.gui.screen().onClose(); + break; + } if (isOreSpawnEditor(minecraft.gui.screen()) && editorFrames >= 2) { + if (isScreen(minecraft.gui.screen(), "BiomeWorldMaterialsScreen") + && !EditorTextRenderProbe.exerciseBiomeDirectory(minecraft.gui.screen(), ClientProbeTestMod::press)) break; validateCaptions(minecraft.gui.screen()); minecraft.gui.screen().onClose(); nextState(3); @@ -160,6 +217,8 @@ && activateWorldSettings(minecraft.gui.screen(), worldCreationButtons)) { case 5: break; case 6: + if (minecraft.level != null) loadedBiomeCount = minecraft.level.registryAccess() + .lookupOrThrow(Registries.BIOME).size(); if (minecraft.gui.screen() instanceof ConfirmScreen) { pressWorldCreationConfirmation((ConfirmScreen) minecraft.gui.screen()); } @@ -205,7 +264,7 @@ && activateWorldSettings(minecraft.gui.screen(), worldCreationButtons)) { default: break; } - } catch (RuntimeException | IOException failure) { + } catch (RuntimeException | IOException | ReflectiveOperationException failure) { fail(minecraft, failure.toString()); } } @@ -228,21 +287,30 @@ private Button nextNavigationButton(CreateWorldScreen root) { private static void validateCaptions(Screen screen) { for (AbstractWidget widget : widgets(screen)) { - // Minecraft 26.3's create-world menu uses an empty-captioned composite - // container; its visible tab controls are checked independently. - if (widget instanceof MenuTabBar) continue; + // Text fields carry their value rather than a caption. Compact lists + // and cogs explain themselves through row text, icons, and tooltips. + if (!(widget instanceof Button) && !(widget instanceof CycleButton)) continue; + if (isVisualControl(widget)) continue; String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); if (caption == null || caption.trim().isEmpty() || caption.contains("options.generic_value") || caption.startsWith("button.orespawn.") || caption.startsWith("option.orespawn.")) { - throw new IllegalStateException("Invalid client caption on " - + screen.getClass().getName() + " widget " + widget.getClass().getName() - + ": " + widget.getMessage()); + throw new IllegalStateException("Invalid client caption in " + + screen.getClass().getSimpleName() + " / " + + widget.getClass().getName() + ": " + widget.getMessage()); } } } + private static boolean isVisualControl(AbstractWidget widget) { + for (Class type = widget.getClass(); type != null; type = type.getSuperclass()) { + String name = type.getSimpleName(); + if ("CogButton".equals(name) || "CompactScrollList".equals(name)) return true; + } + return false; + } + private void validateLongEditorRoundTrip(Minecraft minecraft, Screen parent) { JsonObject root = WorldGeologyProfile.recommended(true).rootCopy(); JsonObject ores = new JsonObject(); @@ -405,6 +473,20 @@ private static boolean isOreSpawnEditor(Screen screen) { "zone.moddev.mc.orespawn.client."); } + private static boolean isScreen(Screen screen, String simpleName) { + return screen != null && simpleName.equals(screen.getClass().getSimpleName()); + } + + private static Button findButton(Screen screen, String captionPrefix) { + for (AbstractWidget widget : widgets(screen)) { + if (widget instanceof Button && widget.visible && widget.active) { + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + if (caption != null && caption.startsWith(captionPrefix)) return (Button) widget; + } + } + return null; + } + private static boolean isWorldSettingsControl(AbstractWidget widget) { return (widget instanceof Button || widget instanceof TabButton) && ChatFormatting.stripFormatting(widget.getMessage().getString()) @@ -522,7 +604,16 @@ private void writeMarker() throws IOException { Properties values = new Properties(); values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened)); values.setProperty("long_editor_roundtrip", Boolean.toString(longEditorRoundTrip)); + values.setProperty("creation_biome_count", Integer.toString(creationBiomeCount)); + values.setProperty("static_biome_count", Integer.toString(staticBiomeCount)); + values.setProperty("loaded_biome_count", Integer.toString(loadedBiomeCount)); values.setProperty("editor_routes", Integer.toString(editorRoutes.size())); + values.setProperty("ore_sources_opened", Boolean.toString(oreSourcesOpened)); + values.setProperty("mods_opened", Boolean.toString(modsOpened)); + values.setProperty("editor_text_verified", Boolean.toString(EditorTextRenderProbe.completed())); + values.setProperty("biome_input_verified", Boolean.toString(EditorTextRenderProbe.completed())); + values.setProperty("ore_source_input_verified", Boolean.toString(EditorTextRenderProbe.oreSourceInputVerified())); + values.setProperty("main_menu_layout_verified", Boolean.toString(MainMenuLayoutProbe.completed())); values.setProperty("editor_classes", editorRoutes.toString()); values.setProperty("first_world_rendered", Boolean.toString(firstWorldFrames >= 8)); values.setProperty("reload_rendered", Boolean.toString(reloadWorldFrames >= 8)); diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/EditorTextRenderProbe.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/EditorTextRenderProbe.java new file mode 100644 index 00000000..90a99351 --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/EditorTextRenderProbe.java @@ -0,0 +1,262 @@ +package zone.moddev.mc.orespawn.clientprobe; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +import com.mojang.blaze3d.platform.InputConstants; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.renderer.state.gui.GuiRenderState; +import net.minecraft.network.chat.Component; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.input.MouseButtonInfo; +import net.minecraft.util.FormattedCharSequence; + +/** Checks the text Minecraft actually queued, not just the labels stored by a widget. */ +final class EditorTextRenderProbe { + private static final Set verifiedLayouts = new HashSet<>(); + private static int biomeStage; + private static int originalWidth; + private static int originalHeight; + private static boolean biomeInputVerified; + private static boolean oreSourceInputVerified; + + private EditorTextRenderProbe() { } + + static void verify(Screen screen, GuiGraphicsExtractor graphics) throws ReflectiveOperationException { + GuiRenderState state = (GuiRenderState) field(graphics, "guiRenderState"); + List texts = new ArrayList<>(); + state.forEachText(text -> { + try { + StringBuilder label = new StringBuilder(); + ((FormattedCharSequence) field(text, "text")).accept((index, style, codepoint) -> { + label.appendCodePoint(codepoint); + return true; + }); + texts.add(new Text(label.toString(), (int) field(text, "x"), (int) field(text, "y"), + (int) field(text, "color"), text.bounds() != null)); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Cannot inspect the native GUI text state", exception); + } + }); + Class listType = Class.forName("zone.moddev.mc.orespawn.client.CompactScrollList"); + int checkedRows = 0; + for (var child : screen.children()) { + if (!listType.isInstance(child) || !((AbstractWidget) child).visible) continue; + AbstractWidget widget = (AbstractWidget) child; + int first = (int) invoke(listType, child, "firstIndex"); + int end = Math.min((int) invoke(listType, child, "size"), + first + (int) invoke(listType, child, "visibleRows")); + for (int index = first; index < end; index++) { + String label = (String) invoke(listType, child, "rowText", index); + int textWidth = Math.max(0, (int) invoke(listType, child, "contentRight") - widget.getX() + - Math.max(0, (int) invoke(listType, child, "rowActionWidth", index)) - 8); + var font = Minecraft.getInstance().font; + if (font.width(label) > textWidth) { + label = font.plainSubstrByWidth(label, Math.max(0, textWidth - font.width("..."))) + "..."; + } + int y = (int) invoke(listType, child, "rowY", index) + + Math.max(2, ((int) field(child, listType, "rowHeight") - 8) / 2); + int color = (int) invoke(listType, child, "rowColor", index); + requireText(texts, label, widget.getX() + 4, y, color); + checkedRows++; + } + int scrollbarX = (int) invoke(listType, child, "contentRight") + 5; + for (String arrow : List.of("^", "v")) { + int y = widget.getY() + (arrow.equals("^") ? 0 : widget.getHeight() - 9); + requireText(texts, arrow, scrollbarX - Minecraft.getInstance().font.width(arrow) / 2, + y, 0xFFDDDDDD); + } + } + String name = screen.getClass().getSimpleName(); + if (name.equals("BiomeWorldMaterialsScreen")) { + boolean detail = screen.width >= 400 || (boolean) field(screen, "compactDetail"); + if (detail && field(screen, "selectedId") != null) { + int statusY = (screen.width < 400 ? 34 : 24) + 22; + boolean statusVisible = List.of("unmanaged", "provider_managed", "disabled", "missing", + "layered", "user_replaced").stream().anyMatch(status -> { + String label = Component.translatable("value.orespawn.biome.status." + status).getString(); + return texts.stream().anyMatch(text -> text.label.equals(label) && text.y == statusY + && text.visible && (text.color >>> 24) == 255); + }); + if (!statusVisible) throw new IllegalStateException("Selected biome status was not rendered"); + } + if (checkedRows > 0 || (screen.width < 400 && detail)) { + String key = screen.width + "x" + screen.height + (screen.width < 400 && detail ? ":detail" : ""); + if (verifiedLayouts.add(key)) System.out.println("EDITOR_TEXT_RENDER_PASS " + key + " rows=" + checkedRows); + } + } + } + + static boolean exerciseBiomeDirectory(Screen screen, Consumer