diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc461d3..f19bd97a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,78 @@ -name: CI +name: OreSpawn 1.12 CI -on: [push, pull_request] -#on: -# push: -# branches: [ master-1.12 ] -# pull_request: -# # The branches below must be a subset of the branches above -# branches: [ master-1.12 ] -# types: [opened, synchronize, reopened] +on: + push: + branches: + - master-1.12.2 + - 'feature/**' + pull_request: + branches: + - master-1.12.2 + +permissions: + contents: read + +concurrency: + group: orespawn-1.12-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build: + name: Build, test, and audit runs-on: ubuntu-latest - name: Build + timeout-minutes: 60 + steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Java 8 toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install Java 17 for Gradle + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: microsoft + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + + - name: Make the wrapper executable + run: chmod +x ./gradlew + + - name: Build, test, and audit release artifacts + run: >- + ./gradlew clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums + verifyEclipseProductionClasspath --no-daemon --stacktrace + + - name: Upload audited release candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: OreSpawn-1.12.2-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/OreSpawn-4.0.8.112021.jar + build/libs/OreSpawn-4.0.8.112021-sources.jar + build/libs/OreSpawn-4.0.8.112021-javadoc.jar + build/release/SHA256SUMS + CHANGELOG.txt + + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - java-version: 8 - - run: chmod a+x gradlew - - run: ./gradlew --version --no-daemon - - run: ./gradlew setupCIWorkspace -S - - run: ./gradlew clean build -S + name: OreSpawn-1.12.2-diagnostics-${{ github.sha }} + if-no-files-found: ignore + retention-days: 14 + path: | + build/test-results/** + build/reports/** + build/*-run/logs/** + build/legacy-abi/**/run/logs/** + build/*-integration-run/**/*.properties + build/problems/** diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d5a02752..65d4f12e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,73 +1,55 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: [push, pull_request] -#on: -# push: -# branches: [ master-1.12 ] -# pull_request: -# # The branches below must be a subset of the branches above -# branches: [ master-1.12 ] -# types: [opened, synchronize, reopened] -# schedule: -# - cron: '43 7 * * 4' +name: CodeQL + +on: + push: + branches: + - master-1.12.2 + - 'feature/**' + pull_request: + branches: + - master-1.12.2 + schedule: + - cron: '43 7 * * 4' + +permissions: + actions: read + contents: read + security-events: write jobs: analyze: - name: Analyze + name: Analyze Java runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'java' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + timeout-minutes: 45 steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Java 8 toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install Java 17 for Gradle + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: microsoft + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 + with: + languages: java-kotlin + + - name: Compile production code + run: | + chmod +x ./gradlew + ./gradlew clean classes --no-daemon --stacktrace + + - name: Analyze + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml new file mode 100644 index 00000000..83bc858f --- /dev/null +++ b/.github/workflows/release-on-tag.yml @@ -0,0 +1,94 @@ +name: Start OreSpawn release from tag + +on: + push: + tags: + - '*.*.*.*' + +permissions: + actions: read + contents: read + +concurrency: + group: orespawn-release-starter-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + validate-release-tag: + name: Validate tag for manual release confirmation + if: github.repository == 'MinecraftModDevelopmentMods/OreSpawn' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Validate release tag, target metadata, and prior CI + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + value() { sed -n "s/^$1=//p" gradle.properties; } + release_version="$(value mod_version)" + minecraft_version="$(value minecraft_version)" + loader_name="$(value loader_name)" + loader_code="$(value loader_code)" + + IFS=. read -r mc_major mc_minor mc_patch extra <<<"$minecraft_version" + if [[ -n "${extra:-}" || -z "${mc_major:-}" || -z "${mc_minor:-}" ]]; then + echo "Invalid minecraft_version=$minecraft_version" >&2 + exit 1 + fi + mc_patch="${mc_patch:-0}" + if [[ ! "$mc_major" =~ ^[0-9]+$ || ! "$mc_minor" =~ ^[0-9]+$ || ! "$mc_patch" =~ ^[0-9]+$ ]]; then + echo "Invalid minecraft_version=$minecraft_version" >&2 + exit 1 + fi + case "$loader_name:$loader_code" in + forge:1|neoforge:2) ;; + *) echo "Invalid loader metadata $loader_name/$loader_code" >&2; exit 1 ;; + esac + printf -v minor_padded '%02d' "$((10#$mc_minor))" + printf -v patch_padded '%02d' "$((10#$mc_patch))" + target_suffix="${mc_major}${minor_padded}${patch_padded}${loader_code}" + + if [[ ! "$release_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.${target_suffix}$ ]]; then + echo "mod_version $release_version does not match $minecraft_version $loader_name target $target_suffix" >&2 + exit 1 + fi + if [[ "$GITHUB_REF_NAME" != "$release_version" ]]; then + echo "Release tag must equal mod_version $release_version; found $GITHUB_REF_NAME" >&2 + exit 1 + fi + + successful_ci="$(gh api \ + "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name == "Build, test, and audit" and .conclusion == "success")] | length')" + if [[ "$successful_ci" -lt 1 ]]; then + echo "The tagged commit has no successful Build, test, and audit check" >&2 + exit 1 + fi + + - name: Record the required manual publication step + env: + RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/workflows/deploy-release.yml + run: | + { + echo "## Release candidate validated" + echo + echo "Tag \`$GITHUB_REF_NAME\` matches the selected target and has a successful Build, test, and audit check." + echo + echo "**Nothing has been published.**" + echo + echo "To continue, open [Deploy OreSpawn release]($RELEASE_WORKFLOW_URL), select **Run workflow**, and enter:" + echo + echo "- release_version: \`$GITHUB_REF_NAME\`" + echo "- curseforge_release_level: \`release\`, \`beta\`, or \`alpha\`" + echo "- confirm_live_publication: \`true\`" + echo + echo "The dispatcher builds and audits the immutable bundle before the separate \`release\` environment approval gate." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml deleted file mode 100644 index c9c52a54..00000000 --- a/.github/workflows/sonarqube.yml +++ /dev/null @@ -1,30 +0,0 @@ -on: [push, pull_request] -#on: -# push: -# branches: -# - master-1.12 -# pull_request: -# types: [opened, synchronize, reopened] -# -name: SonarCloud -jobs: - sonarcloud: - name: SonarCloud Scan - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - # Disabling shallow clone is recommended for improving relevancy of reporting - fetch-depth: 0 - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - - name: SonarCloud Quality Gate check - uses: SonarSource/sonarqube-quality-gate-action@master - # Force to fail step after specific time - timeout-minutes: 5 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/validate-gradle-build.yml b/.github/workflows/validate-gradle-build.yml index 528f4b5a..8959c1a8 100644 --- a/.github/workflows/validate-gradle-build.yml +++ b/.github/workflows/validate-gradle-build.yml @@ -1,11 +1,23 @@ name: Validate Gradle Wrapper -on: [push, pull_request] +on: + push: + branches: + - master-1.12.2 + - 'feature/**' + pull_request: + branches: + - master-1.12.2 + +permissions: + contents: read jobs: validation: - name: "Validation" + name: Validation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: gradle/wrapper-validation-action@v1 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Validate wrapper integrity + uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 diff --git a/CHANGELOG.txt b/CHANGELOG.txt index c525e85f..da3af278 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,12 @@ +Version 4.0.8.112021 + +* Preserve long host, tag, and biome-list values when OreSpawn editors load + and save an existing profile without user changes. +* Add reproducible ForgeGradle 7 builds and guarded Maven, CurseForge, and + GitHub release automation. +* Keep the Forge 1.12.2 packaged access-transformer repair introduced in + 4.0.7.112021. + Version 4.0.7.112021 * Fix Forge 1.12.2 packaged jars not declaring their access transformer, which diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index a3081bfe..00000000 --- a/Jenkinsfile +++ /dev/null @@ -1,128 +0,0 @@ -pipeline { - agent any - environment { - GRADLE_OPTS = '-Dorg.gradle.caching=true -Dorg.gradle.configureondemand=true -Dorg.gradle.warning.mode=all' -// JAVA_OPTS = '' - } - options { - ansiColor('xterm') - } - tools { -// git 'Git' - gradle 'Gradle 4.9' - jdk 'oraclejdk8' - } - stages { - stage('prebuild') { - steps { - sh 'rm -rf build/libs' - sh 'chmod +x gradlew' - sh 'java -version' - sh 'gradle -version' - sh './gradlew -version' - sh 'export' - } - } - stage('CIWorkspace') { - steps { - withGradle { - sh './gradlew clean setupCiWorkspace -S' - } - } - } - stage('build') { - steps { - withGradle { - sh './gradlew build -S' - } - } - } - stage('test') { - steps { - withGradle { - sh './gradlew test -S' - } - } - } - stage('publish') { - steps { - withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { - withGradle { - sh './gradlew publish -S' - } - } - } - } - stage('CurseForge') { - steps { - withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { - withGradle { - sh './gradlew -x publish curseforge -S' - } - } - } - } - stage('SonarQube') { - tools { - jdk "oraclejdk11" - } - environment { - scannerHome = tool 'SonarQube' - } - steps { -// withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { -// withGradle { -// sh './gradlew sonarqube -S' -// } -// } - withSonarQubeEnv(installationName: 'SonarCloud', , envOnly: false) { - sh "${scannerHome}/bin/sonar-scanner -Dsonar.java.jdkHome=${JAVA_HOME}" - } - } - } - stage('postbuild') { - steps { - archiveArtifacts artifacts: 'build/libs/*.jar', followSymlinks: false - javadoc javadocDir: 'build/docs/javadoc', keepAll: false - fingerprint 'build/libs/*.zip' - junit allowEmptyResults: true, testResults: '**/build/test-results/junit-platform/*.xml' - jacoco classPattern: '**/build/classes/java', execPattern: '**/build/jacoco/**.exec', sourceInclusionPattern: '**/*.java', sourcePattern: '**/src/main/java' - findBuildScans() - recordIssues(tools: [java()]) - recordIssues(tools: [javaDoc()]) -// if (fileExists('')) { -// recordIssues(tools: [errorProne(pattern: 'ReportFilePattern', reportEncoding: 'UTF-8')]) -// } else { -// echo 'No ErrorProne report available' -// } - if (fileExists('**/build/reports/checkstyle/*.xml')) { - recordIssues(tools: [checkStyle(pattern: '**/build/reports/checkstyle/*.xml')]) - } else { - echo 'No CheckStyle report available' - } - if (fileExists('**/build/reports/pmd/*.xml')) { - recordIssues(tools: [pmdParser(pattern: '**/build/reports/pmd/*.xml')]) - } else { - echo 'No PMD report available' - } - if (fileExists('*/build/reports/findbugs/*.xml')) { - recordIssues(tools: [findBugs(pattern: '*/build/reports/findbugs/*.xml', useRankAsPriority: true)]) - } else { - echo 'No FindBugs report available' - } - } - when { expression { fileExists('**/build/reports/spotbugs/*.xml') } } - steps { - recordIssues(tools: [spotBugs(pattern: '**/build/reports/spotbugs/*.xml', useRankAsPriority: true)]) - } - when { expression { fileExists('**/build/test-results/junit-platform/*.xml') } } - steps { - recordIssues(tools: [junitParser(pattern: '**/build/test-results/junit-platform/*.xml')]) - } - when { expression { fileExists('**/sonar-report.json') } } - steps { - recordIssues(tools: [sonarQube(pattern: '**/sonar-report.json')]) - } - } - } -} diff --git a/README.md b/README.md index 976a00dc..110531d4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +[![Discord](https://img.shields.io/badge/Discord-MMD-green.svg?style=flat&logo=Discord)](https://discord.moddev.zone) +[![CurseForge downloads](https://cf.way2muchnoise.eu/full_mmd-orespawn_downloads.svg)](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn) +[![Supported Minecraft versions](https://cf.way2muchnoise.eu/versions/Minecraft_mmd-orespawn_all.svg)](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn) +[![Build, test, and audit](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml/badge.svg?branch=master-1.12.2)](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml?query=branch%3Amaster-1.12.2) + # MMD OreSpawn OreSpawn 4 is a provider-driven world-generation engine for Minecraft 1.12.2. @@ -5,7 +10,7 @@ It gives mods and modpacks one place to configure ores, deposit shapes, optional rock strata and geomes, provider-owned underground fluid deposits, biome palettes and world materials, flat bedrock, and bounded ore retrogen. -This branch builds target-qualified version `4.0.7.112021`: the OreSpawn 4.0.7 +This branch builds target-qualified version `4.0.8.112021`: the OreSpawn 4.0.8 feature set for Minecraft 1.12.2 and Forge. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. @@ -102,11 +107,13 @@ exported to `config/orespawn-guide/` without overwriting existing files. ## Building -Use a Java 8 JDK from the repository root: +Run Gradle with Java 17 from the repository root. Install the exact Temurin +`8.0.502+7` toolchain used to compile production code and test fixtures for +Minecraft 1.12.2; the build rejects a different Java 8 toolchain: ```powershell -.\gradlew.bat clean build javadoc --no-daemon -.\gradlew.bat genEclipseRuns eclipse --no-daemon +.\gradlew.bat clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums --no-daemon +.\gradlew.bat genEclipseRuns verifyEclipseProductionClasspath --no-daemon ``` `build` runs the standard `check` lifecycle. In addition to the JUnit suite, @@ -118,10 +125,13 @@ survive, validates provider-rock vanilla springs and an external ore-pattern registration, then reopens and checks the exact saved world. The fixture is not included in OreSpawn's published jars. -Run both `genEclipseRuns` and `eclipse` after importing or refreshing this -ForgeGradle 3 project in Eclipse. This branch uses the Gradle 4.9 wrapper, -Forge 14.23.5.2859, the `stable_39` MCP mappings, and pack format 3. Published -jars are SRG-reobfuscated for the Forge 1.12 runtime. +Import or refresh the project with Eclipse Buildship, then run +`genEclipseRuns` and `verifyEclipseProductionClasspath`. This branch uses +ForgeGradle 7.0.34, the Gradle 9.6.1 wrapper, Forge 14.23.5.2859, the +`stable_39` MCP mappings, and pack format 3. Ordinary Eclipse launches exclude +tests and fixtures. Published jars are deterministic, SRG-reobfuscated for the +Forge 1.12 runtime, audited for their access transformer and contents, and +accompanied by SHA-256 checksums. Machine-specific `AGENTS.md` and `agent-notes/` files are intentionally ignored. Public developer and AI integration guidance lives in `docs/` and is included diff --git a/build.gradle b/build.gradle index 26bbe73c..f669aaab 100644 --- a/build.gradle +++ b/build.gradle @@ -1,249 +1,394 @@ -buildscript { - repositories { - maven { url = 'https://maven.minecraftforge.net/' } - mavenCentral() +import groovy.json.JsonSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile +import org.apache.tools.ant.filters.FixCrLfFilter + +plugins { + id 'java' + id 'eclipse' + id 'idea' + id 'maven-publish' + id 'net.minecraftforge.renamer' version '1.1.5' + id 'net.minecraftforge.accesstransformers' version '2.0.0' + id 'net.minecraftforge.gradle' version '7.0.34' +} + +group = project.mod_group +version = project.mod_version +base.archivesName = 'OreSpawn' + +def versionParts = project.mod_version.toString().tokenize('.') +if (versionParts.size() != 4 || !versionParts.every { it ==~ /\d+/ }) { + throw new GradleException("mod_version must use Major.Minor.Bug.Target numeric form: ${project.mod_version}") +} + +def minecraftVersionParts = project.minecraft_version.toString().tokenize('.') +if (minecraftVersionParts.size() < 2 || minecraftVersionParts.size() > 3 + || !minecraftVersionParts.every { it ==~ /\d+/ }) { + throw new GradleException("minecraft_version must use major.minor or major.minor.patch numeric form: " + + project.minecraft_version) +} +def minecraftPatch = minecraftVersionParts.size() == 3 ? minecraftVersionParts[2] : '0' +def expectedTargetVersion = "${minecraftVersionParts[0]}" \ + + "${minecraftVersionParts[1].padLeft(2, '0')}" \ + + "${minecraftPatch.padLeft(2, '0')}" \ + + project.loader_code +if (versionParts[3] != expectedTargetVersion) { + throw new GradleException("mod_version target ${versionParts[3]} does not match " + + "Minecraft ${project.minecraft_version} ${project.loader_name} target ${expectedTargetVersion}") +} + +ext.functional_version = versionParts[0..2].join('.') +ext.display_version = project.mod_version +ext.release_tag = project.mod_version + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(8) + vendor = JvmVendorSpec.ADOPTIUM } - dependencies { - classpath group: 'net.minecraftforge.gradle', name: 'ForgeGradle', version: '3.+', changing: true + withSourcesJar() + withJavadocJar() +} + +tasks.withType(JavaCompile).configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(8) + vendor = JvmVendorSpec.ADOPTIUM } + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + options.encoding = 'UTF-8' + options.compilerArgs.addAll(['-Xmaxerrs', '1000']) +} + +tasks.named('compileTestJava', JavaCompile) { + options.compilerArgs.add('-proc:none') } -apply plugin: 'net.minecraftforge.gradle' -apply plugin: 'eclipse' -apply plugin: 'maven-publish' +tasks.withType(Test).configureEach { + useJUnitPlatform() + workingDir = project.projectDir +} -version = mod_version -group = mod_group_id -archivesBaseName = "OreSpawn-${minecraft_version}" +tasks.withType(Javadoc).configureEach { + failOnError = false + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('notimestamp', true) +} -sourceCompatibility = targetCompatibility = compileJava.sourceCompatibility = compileJava.targetCompatibility = '1.8' +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true +} -println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}" +def archiveTextSuffixes = [ + '.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.lang', + '.mcmeta', '.md', '.properties', '.txt', '.xml' +] +def archiveTextPatterns = archiveTextSuffixes.collect { "**/*${it}".toString() } +archiveTextPatterns.addAll(['**/element-list', '**/package-list']) +def normalizeArchiveLineEndings = { details -> + details.filter(FixCrLfFilter, + eol: FixCrLfFilter.CrLf.newInstance('lf'), + eof: FixCrLfFilter.AddAsisRemove.newInstance('asis')) +} minecraft { mappings channel: project.mapping_channel, version: project.mapping_version - accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg') + accessTransformer = 'META-INF/accesstransformer.cfg' runs { - client { - workingDirectory project.file('run') - property 'forge.logging.markers', 'REGISTRIES' - property 'forge.logging.console.level', 'debug' - } - - server { - workingDirectory project.file('run') - property 'forge.logging.markers', 'REGISTRIES' - property 'forge.logging.console.level', 'debug' - args '--nogui' + configureEach { + workingDir.convention layout.projectDirectory.dir('run') + systemProperty 'forge.logging.console.level', 'debug' } - - surfaceIntegrationFresh { - workingDirectory project.file("${buildDir}/surface-integration-run") - property 'forge.logging.console.level', 'info' - property 'surfaceprobe.integrationPhase', 'fresh' + register('client') + register('server') { args '--nogui' } + } +} - surfaceIntegrationReload { - workingDirectory project.file("${buildDir}/surface-integration-run") - property 'forge.logging.console.level', 'info' - property 'surfaceprobe.integrationPhase', 'reload' - args '--nogui' +// Forge 1.12 expects each development mod to expose classes and resources from +// one classpath root. ForgeGradle 7 splits those outputs, so build a separate +// merged launch root without contaminating either canonical source-set output. +def forge12DevelopmentOutput = layout.buildDirectory.dir('forge12-development/main') +def syncForge12DevelopmentResources = tasks.register('syncForge12DevelopmentResources', Sync) { + group = 'ide' + description = 'Merges processed resources into the Forge 1.12 development launch root.' + dependsOn tasks.named('classes') + from tasks.named('compileJava', JavaCompile).flatMap { it.destinationDirectory } + from sourceSets.main.output.resourcesDir + into forge12DevelopmentOutput +} +tasks.configureEach { + if (name == 'runClient' || name == 'runServer') { + dependsOn syncForge12DevelopmentResources + doFirst { + Set splitMainOutputs = [ + tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile, + sourceSets.main.output.resourcesDir + ] as Set + classpath = files(forge12DevelopmentOutput, classpath.filter { + !splitMainOutputs.contains(it) + }) } } } -sourceSets.main.resources { srcDir 'src/generated/resources' } - -// ForgeGradle 3 resolves its 1.12 mapped dependency during configuration. If -// `clean` deletes build/fg_cache in the same invocation, compileJava retains a -// path to a jar that no longer exists. Treat that directory as the target's -// dependency cache; all OreSpawn classes, resources, jars, reports, fixtures, -// worlds, and IDE output are still removed by clean. -clean.setDelete(fileTree(buildDir) { - exclude 'fg_cache/**' -}) - repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + + exclusiveContent { + forRepository { + maven { + name = 'Sponge' + url = 'https://repo.spongepowered.org/repository/maven-public' + } + } + filter { includeGroupAndSubgroups('org.spongepowered') } + } + mavenCentral() + maven { + name = 'MinecraftLibraries' + url = 'https://libraries.minecraft.net/' + } } -dependencies { - minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" - - testCompile 'org.junit.jupiter:junit-jupiter-api:5.10.2' - testCompile 'org.junit.jupiter:junit-jupiter-params:5.10.2' - testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.10.2' - testRuntime 'org.junit.platform:junit-platform-launcher:1.10.2' -} +def legacyFixtureRoot = file("${rootDir}/ci-fixtures") +def legacyFixtureArtifacts = new File(legacyFixtureRoot, 'artifacts') +def legacyFixtureWorlds = new File(legacyFixtureRoot, 'worlds') +def legacyMineralogy110OracleJar = new File( + legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') +def legacyMineralogy112OracleJar = new File( + legacyFixtureArtifacts, 'Mineralogy-1.12.2-3.8.0.53.jar') +def legacyFixtureHashes = [ + 'artifacts/Mineralogy-1.10.2-3.3.8.26.jar': + '88A6237C9A0E2C8891718B68C373E741C78B8494F5E68D8093CA9339F3BC4D87', + 'artifacts/Mineralogy-1.12.2-3.8.0.53.jar': + 'C42E608E5662A94138BD2461019D33283F96E3FB66E28DB91C00A49E9A8005CD', + 'worlds/os3-331-default-source.zip': + '2852FA549C7A952CCC0EAE1454057CA81BD91F5BB323BEA1030452BB1D82FDFD' +] -task sourcesJar(type: Jar, dependsOn: classes) { - classifier = 'sources' - from sourceSets.main.allSource +task verifyLegacyFixtures { + group = 'verification' + description = 'Verifies the sealed ABI and migration corpus used only by isolated tests.' + inputs.files legacyFixtureHashes.keySet().collect { new File(legacyFixtureRoot, it) } + doLast { + legacyFixtureHashes.each { String relativePath, String expectedHash -> + File fixture = new File(legacyFixtureRoot, relativePath) + if (!fixture.isFile()) { + throw new GradleException("Missing sealed legacy fixture: ${fixture}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + fixture.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) + } + } + String actualHash = digest.digest().encodeHex().toString().toUpperCase() + if (actualHash != expectedHash) { + throw new GradleException("Legacy fixture hash mismatch for ${relativePath}: " + + "expected ${expectedHash}, found ${actualHash}") + } + } + } } -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' - from javadoc.destinationDir +dependencies { + implementation minecraft.dependency( + "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") + compileOnly 'org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' } -artifacts { - archives sourcesJar - archives javadocJar +tasks.named('compileTestJava', JavaCompile) { + dependsOn verifyLegacyFixtures } - -jar { - manifest { - attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', - 'Specification-Version' : '1', - 'Implementation-Title' : project.name, - 'Implementation-Version' : version, - 'Implementation-Vendor' : 'SkyBlade1978', - 'Implementation-Timestamp' : new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), - 'OreSpawn-API-Version' : '1', - // Forge 1.12 discovers mod access transformers only through - // this manifest attribute. The development configuration - // above is not consulted when a packaged jar is loaded. - 'FMLAT' : 'accesstransformer.cfg' - ]) - } - finalizedBy 'reobfJar' +tasks.named('test', Test) { + dependsOn verifyLegacyFixtures + systemProperty 'orespawn.mineralogy110Oracle', legacyMineralogy110OracleJar.absolutePath + systemProperty 'orespawn.mineralogy112Oracle', legacyMineralogy112OracleJar.absolutePath } -task verifyPublishedJarRuntimeContract(dependsOn: 'reobfJar') { +tasks.register('verifyLegacyOracleIsolation') { group = 'verification' - description = 'Verifies the reobfuscated jar advertises and contains its Forge 1.12 access transformer.' + description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' + dependsOn verifyLegacyFixtures + doLast { - java.util.jar.JarFile published = new java.util.jar.JarFile(jar.archivePath) - try { - String declared = published.manifest.mainAttributes.getValue('FMLAT') - if (declared != 'accesstransformer.cfg') { - throw new GradleException("Published jar has invalid FMLAT manifest entry: ${declared}") - } - java.util.jar.JarEntry transformer = published.getJarEntry('META-INF/accesstransformer.cfg') - if (transformer == null) { - throw new GradleException('Published jar is missing META-INF/accesstransformer.cfg') - } - String rules = published.getInputStream(transformer).getText('UTF-8') - [ - 'public-f net.minecraft.world.WorldProvider field_76578_c', - 'public-f net.minecraft.world.gen.ChunkGeneratorOverworld field_186001_t' - ].each { String required -> - if (!rules.readLines().any { String line -> line.trim().startsWith(required) }) { - throw new GradleException("Published access transformer is missing rule: ${required}") - } + Set oracles = [legacyMineralogy110OracleJar.canonicalFile, + legacyMineralogy112OracleJar.canonicalFile] as Set + def canonicalFiles = { FileCollection classpath -> + classpath.files.collect { it.canonicalFile } as Set + } + Map productionClasspaths = [ + mainCompile: sourceSets.main.compileClasspath, + mainRuntime: sourceSets.main.runtimeClasspath, + testCompile: sourceSets.test.compileClasspath, + testRuntime: sourceSets.test.runtimeClasspath + ] + productionClasspaths.each { String name, FileCollection classpath -> + Set leaked = canonicalFiles(classpath).intersect(oracles) + if (!leaked.isEmpty()) { + throw new GradleException("The sealed Mineralogy oracles leaked into ${name}: ${leaked}") } - } finally { - published.close() } } } +tasks.named('check') { + dependsOn tasks.named('verifyLegacyOracleIsolation') +} -check.dependsOn verifyPublishedJarRuntimeContract - -processResources { - inputs.property 'version', project.version - inputs.property 'minecraft_version', minecraft_version - inputs.property 'forge_version_range', forge_version_range - inputs.property 'loader_version_range', loader_version_range - inputs.property 'minecraft_version_range', minecraft_version_range - - filesMatching('mcmod.info') { - expand([ - version: project.version, - mcversion: minecraft_version - ]) - } - from('docs/AGENTS.md') { - into '' - rename { 'AGENTS.md' } - } - from('docs') { - into 'META-INF/orespawn/docs' - } +def java8Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(8) + vendor = JvmVendorSpec.ADOPTIUM } -publishing { - publications { - mavenJava(MavenPublication) { - artifact jar - artifact sourcesJar - artifact javadocJar +tasks.register('verifyJava8Toolchain') { + group = 'verification' + description = 'Requires the checksum-pinned Temurin 8.0.502+7 release toolchain.' + doLast { + def metadata = java8Launcher.get().metadata + if (project.java_toolchain_version != '8.0.502+7' + || metadata.vendor.toString() != 'Eclipse Temurin' + || metadata.javaRuntimeVersion != '1.8.0_502-b07') { + throw new GradleException("Expected Temurin ${project.java_toolchain_version}, found " + + "${metadata.vendor} ${metadata.javaRuntimeVersion} at ${metadata.installationPath}") } } - repositories { - maven { url "file:///${project.projectDir}/mcmodsrepo" } - } } - -tasks.withType(JavaCompile) { - sourceCompatibility = '1.8' - targetCompatibility = '1.8' - options.encoding = 'UTF-8' - options.compilerArgs += ['-Xmaxerrs', '1000'] +tasks.named('check') { + dependsOn tasks.named('verifyJava8Toolchain') } - -javadoc { - options.encoding = 'UTF-8' - options.addStringOption('Xdoclint:none', '-quiet') +def configureFromForgeRun = { JavaExec process, String runTaskName -> + process.actions.clear() + process.dependsOn { + JavaExec run = tasks.getByName(runTaskName) as JavaExec + run.taskDependencies.getDependencies(run) + } + process.doLast { + JavaExec run = tasks.getByName(runTaskName) as JavaExec + String runConfigurationName = runTaskName.substring(3).uncapitalize() + def runOptions = minecraft.runs.getByName(runConfigurationName) + File originalConfiguredWorkingDir = runOptions.workingDir.get().asFile + File originalWorkingDir = run.workingDir + List originalArgs = new ArrayList<>(run.args) + List originalJvmArgs = new ArrayList<>(run.jvmArgs) + Map originalSystemProperties = new LinkedHashMap<>(run.systemProperties) + Map originalEnvironment = new LinkedHashMap<>(run.environment) + FileCollection originalClasspath = files(run.classpath.files) + String originalMinHeap = run.minHeapSize + String originalMaxHeap = run.maxHeapSize + String originalMainClass = run.mainClass.orNull + try { + File forgeConfig = new File(process.workingDir, 'config/forge.cfg') + if (!forgeConfig.isFile()) { + forgeConfig.parentFile.mkdirs() + forgeConfig.setText('''general { + B:disableVersionCheck=true } -test { - useJUnitPlatform() - // The parity test opens each published engine in its own URLClassLoader. - // Keeping these jars out of Gradle configurations also keeps Forge's - // ordinary Eclipse launch from discovering two Mineralogy mods. - systemProperty 'orespawn.mineralogy110Oracle', - file('../migration-fixtures/sources/artifacts/Mineralogy-1.10.2-3.3.8.26.jar').absolutePath - systemProperty 'orespawn.mineralogy112Oracle', - file('../migration-fixtures/sources/artifacts/Mineralogy-1.12.2-3.8.0.53.jar').absolutePath +version_checking { + B:Global=false +} +''', 'UTF-8') + } + runOptions.workingDir.set(process.workingDir) + run.workingDir(process.workingDir) + run.args(process.args) + run.jvmArgs(process.jvmArgs) + run.systemProperties(process.systemProperties) + run.environment(process.environment) + Set splitMainOutputs = [ + tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile, + sourceSets.main.output.resourcesDir + ] as Set + run.setClasspath(files(forge12DevelopmentOutput, run.classpath.filter { + !splitMainOutputs.contains(it) + })) + run.exec() + } finally { + runOptions.workingDir.set(originalConfiguredWorkingDir) + run.workingDir(originalWorkingDir) + run.setArgs(originalArgs) + run.setJvmArgs(originalJvmArgs) + run.setSystemProperties(originalSystemProperties) + run.setEnvironment(originalEnvironment) + run.setClasspath(originalClasspath) + run.minHeapSize = originalMinHeap + run.maxHeapSize = originalMaxHeap + if (originalMainClass == null) { + run.mainClass.unset() + } else { + run.mainClass.set(originalMainClass) + } + } + } } // Runtime processes are not green merely because they exit with code zero. -// Forge 14 can log a fatal worldgen/callback error and still shut down cleanly. +// Forge 14 can log a fatal callback or worldgen failure and still stop cleanly. def acceptedForge14LogNoise = [ ~/Apache Maven library folder was not in the format expected/, ~/\[FML\]: Full: .*maven-artifact-/, ~/\[FML\]: Trimmed: .*maven-artifact/, ~/FML appears to be missing any signature data/, ~/Unable to read a class file correctly/, - ~/There was a problem reading the entry (?:META-INF\/versions\/9\/)?module-info\.class .*probably a corrupt zip/ + ~/There was a problem reading the entry (?:META-INF\/versions\/9\/)?module-info\.class .*probably a corrupt zip/, + ~/There was a problem reading the entry META-INF\/versions\/11\/net\/minecraftforge\/launcher\/shadow\/util\/download\/DownloadUtilsImpl\.class in the jar .*slime-launcher-0\.2\.2\.jar - probably a corrupt zip/ ] def assertRuntimeLogsClean = { File runDirectory, String context -> File crashDirectory = new File(runDirectory, 'crash-reports') if (crashDirectory.isDirectory()) { - def crashes = fileTree(crashDirectory) { include '**/*' }.files.findAll { it.isFile() } + List crashes = fileTree(crashDirectory) { include '**/*' }.files.findAll { it.isFile() } if (!crashes.isEmpty()) { throw new GradleException("${context} produced crash report ${crashes.first()}") } } + Set runtimeLogs = [] as Set File logsDirectory = new File(runDirectory, 'logs') - def failures = [] - def runtimeLogs = [] as Set if (logsDirectory.isDirectory()) { runtimeLogs.addAll(fileTree(logsDirectory) { include '**/*.log' include '**/*.txt' }.files) } - runtimeLogs.addAll(fileTree(runDirectory) { - include '*-console.txt' - }.files) + runtimeLogs.addAll(fileTree(runDirectory) { include '*-console.txt' }.files) + + List failures = [] runtimeLogs.each { File log -> + String completeLog = log.getText('UTF-8') + boolean cleanroomWindowsEpollFallback = completeLog.contains( + '[io.netty.channel.epoll.Epoll]: Epoll support is not available') + && completeLog.contains('Caused by: java.lang.IllegalStateException: Only supported on Linux') + && completeLog.contains('[net.minecraft.network.NetworkSystem]: Using default channel type') int lineNumber = 0 log.eachLine('UTF-8') { String line -> lineNumber++ boolean unexpectedSeverity = (line ==~ /.*\/(?:ERROR|FATAL)\].*/ || line ==~ /.*\s(?:ERROR|FATAL)\s.*/) boolean knownNoise = acceptedForge14LogNoise.any { line =~ it } - boolean oreSpawnCascadingLoad = (line.contains('cascading worldgen lag') - && line.contains('OreSpawn loaded a new chunk')) - boolean fatalText = (line.contains('Encountered an unexpected exception') + || (line.trim() == 'java.lang.ExceptionInInitializerError' + && cleanroomWindowsEpollFallback) + boolean oreSpawnCascadingLoad = line.contains('cascading worldgen lag') + && line.contains('OreSpawn loaded a new chunk') + boolean fatalText = line.contains('Encountered an unexpected exception') || line.contains('Exception stopping the server') || line.contains('Migration audit failed') || line.contains('java.lang.Error:') @@ -254,8 +399,8 @@ def assertRuntimeLogsClean = { File runDirectory, String context -> || line.contains('NoClassDefFoundError') || line.contains('ExceptionInInitializerError') || line.contains('Tried to assign a mutable BlockPos') - || oreSpawnCascadingLoad) - if ((unexpectedSeverity && !knownNoise) || fatalText) { + || oreSpawnCascadingLoad + if (!knownNoise && (unexpectedSeverity || fatalText)) { failures.add("${log.name}:${lineNumber}: ${line}") } } @@ -266,85 +411,96 @@ def assertRuntimeLogsClean = { File runDirectory, String context -> } } -task runtimeLogScannerTest { +tasks.register('verifyExternalRuntimeLogs') { + group = 'verification' + description = 'Applies the release log/crash scanner to a disposable packaged runtime.' + doLast { + if (!project.hasProperty('runtimeLogDirectory')) { + throw new GradleException('runtimeLogDirectory is required') + } + File runtime = file(project.property('runtimeLogDirectory')) + if (!runtime.isDirectory()) { + throw new GradleException("Runtime log directory does not exist: ${runtime}") + } + assertRuntimeLogsClean(runtime, "external packaged runtime ${runtime.name}") + } +} + +tasks.register('runtimeLogScannerTest') { group = 'verification' - description = 'Proves runtime log validation accepts documented Forge noise and rejects real failures.' + description = 'Proves Forge 14 log validation accepts known noise and rejects runtime failures.' doLast { File probe = file("${buildDir}/runtime-log-scanner-test") delete probe - File logs = new File(probe, 'logs'); logs.mkdirs() + File logs = new File(probe, 'logs') + logs.mkdirs() new File(logs, 'latest.log').setText( '[main/ERROR] [FML]: FML appears to be missing any signature data. This is not a good thing\n' - + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') + + '[Server thread/ERROR] [FML]: There was a problem reading the entry META-INF/versions/11/net/minecraftforge/launcher/shadow/util/download/DownloadUtilsImpl.class in the jar C:/cache/slime-launcher-0.2.2.jar - probably a corrupt zip\n' + + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe') new File(logs, 'latest.log').setText( - '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') + '[Server thread/DEBUG] [io.netty.channel.epoll.Epoll]: Epoll support is not available\n' + + 'java.lang.ExceptionInInitializerError\n' + + 'Caused by: java.lang.IllegalStateException: Only supported on Linux\n' + + '[Server thread/INFO] [net.minecraft.network.NetworkSystem]: Using default channel type\n', + 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-cleanroom-windows-epoll-probe') + new File(logs, 'latest.log').setText('java.lang.ExceptionInInitializerError\n', 'UTF-8') boolean rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-unaccounted-initializer-probe') } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime scanner accepted an unaccounted initializer failure') + new File(logs, 'latest.log').setText( + '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') + rejected = false try { assertRuntimeLogsClean(probe, 'scanner-rejection-probe') } catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') + if (!rejected) throw new GradleException('Runtime scanner accepted a mutable BlockPos leak') new File(logs, 'latest.log').setText( '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') rejected = false try { assertRuntimeLogsClean(probe, 'scanner-error-severity-probe') } catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') - new File(probe, 'packaged-forge-fresh-console.txt').setText( - '[Server thread/ERROR] [FML]: java.lang.IllegalAccessError: tried to access field net.minecraft.world.WorldProvider.field_76578_c\n', - 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-packaged-console-probe') } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner ignored a packaged-launch IllegalAccessError') + if (!rejected) throw new GradleException('Runtime scanner accepted an unexpected ERROR line') delete probe } } -check.dependsOn runtimeLogScannerTest +tasks.named('check') { + dependsOn tasks.named('runtimeLogScannerTest') +} def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { source fileTree('src/biomeIntegrationTest/java') classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDir = surfaceIntegrationClasses - sourceCompatibility = '1.8' - targetCompatibility = '1.8' + destinationDirectory = surfaceIntegrationClasses options.encoding = 'UTF-8' } task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { - archiveName = 'surfaceprobe.jar' - destinationDir = file("${buildDir}/surface-integration-fixture") + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = file("${buildDir}/surface-integration-fixture") from surfaceIntegrationClasses from 'src/biomeIntegrationTest/resources' } // Keep the mapped development fixture separate from the copy transformed for -// a real packaged Forge runtime. -task packagedSurfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { - archiveName = 'surfaceprobe-packaged.jar' - destinationDir = file("${buildDir}/surface-integration-fixture") - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' -} - -reobf { - packagedSurfaceIntegrationTestModJar {} +// Forge's real packaged-mod discovery. +def packagedSurfaceIntegrationTestModJar = renamer.classes( + tasks.named('surfaceIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('surface-integration-fixture/surfaceprobe-reobf.jar') } def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") -def surfaceIntegrationMainOutput = file("${buildDir}/surface-integration-fixture/orespawn-main") task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { doLast { delete surfaceIntegrationRunDirectory - delete surfaceIntegrationMainOutput surfaceIntegrationRunDirectory.mkdirs() copy { - from sourceSets.main.output - into surfaceIntegrationMainOutput - } - copy { - from surfaceIntegrationTestModJar.archivePath + from surfaceIntegrationTestModJar.archiveFile into new File(surfaceIntegrationRunDirectory, 'mods') } new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ @@ -361,89 +517,16 @@ max-tick-time=-1 } } -def legacyDevRuntime = fileTree(new File(gradle.gradleUserHomeDir, - 'caches/modules-2/files-2.1/net.minecraftforge/legacydev/0.2.3.1')) { - include '**/legacydev-*-fatjar.jar' -} - -// ForgeGradle 3 initially exposes mergetool's three-side compile stub on a -// completely clean 1.12 build. Its synthetic BUKKIT enum value is not valid in -// the Forge 14 runtime and makes FML's own channel registration fail. Keep the -// mapped runtime intact but restore Forge's native two-side Side class in a -// build-only copy used by integration processes. Nothing from this copy is -// packaged in OreSpawn's published artifacts. -def forge14MappedRuntime = { - File runtime = sourceSets.main.runtimeClasspath.files.find { File candidate -> - candidate.name.startsWith("forge-${minecraft_version}-${forge_version}_mapped_") && - candidate.name.endsWith('.jar') - } - if (runtime == null) { - throw new GradleException('Could not locate the mapped Forge 14 runtime') - } - runtime -} - -def forge14UniversalRuntime = { - File runtime = new File(gradle.gradleUserHomeDir, - "caches/forge_gradle/maven_downloader/net/minecraftforge/forge/" + - "${minecraft_version}-${forge_version}/" + - "forge-${minecraft_version}-${forge_version}-universal.jar") - if (!runtime.isFile()) { - throw new GradleException("Forge 14 universal runtime is missing: ${runtime}") - } - runtime -} - -task forge14RuntimeJar(type: Jar, dependsOn: classes) { - archiveName = 'forge14-runtime.jar' - destinationDir = file("${buildDir}/forge14-runtime") - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - from({ zipTree(forge14MappedRuntime()) }) { - exclude 'net/minecraftforge/fml/relauncher/Side.class' - } - from({ zipTree(forge14UniversalRuntime()) }) { - include 'net/minecraftforge/fml/relauncher/Side.class' - } -} - -def useForge14Runtime = { JavaExec process -> - process.dependsOn forge14RuntimeJar - process.doFirst { - File generated = forge14MappedRuntime() - def runtime = sourceSets.main.runtimeClasspath.files.findAll { File candidate -> - candidate != generated - } - process.classpath = files(forge14RuntimeJar.archivePath, runtime, legacyDevRuntime) - } -} - -// ForgeGradle's ordinary client/server tasks expose the same synthetic -// three-side compile jar as the integration tasks. Route every development -// launch through the corrected runtime copy so command-line and Eclipse -// testing exercise Forge's native CLIENT/SERVER Side enum. -tasks.matching { it.name == 'runClient' || it.name == 'runServer' }.all { JavaExec process -> - useForge14Runtime(process) -} - def createSurfaceProcess = { String phase, Object dependency -> - task("surfaceIntegration${phase}Process", type: JavaExec, - dependsOn: [dependency, 'createSrgToMcp']) { + task("surfaceIntegration${phase}Process", type: JavaExec, dependsOn: dependency) { group = 'verification' - main = 'net.minecraftforge.legacydev.MainServer' - classpath = files(sourceSets.main.runtimeClasspath, legacyDevRuntime) workingDir surfaceIntegrationRunDirectory - environment 'mainClass', 'net.minecraft.launchwrapper.Launch' - environment 'MCP_TO_SRG', file("${buildDir}/createSrgToMcp/output.srg").absolutePath - environment 'MOD_CLASSES', surfaceIntegrationMainOutput.absolutePath - environment 'tweakClass', 'net.minecraftforge.fml.common.launcher.FMLServerTweaker' - systemProperty 'forge.logging.console.level', 'info' systemProperty 'surfaceprobe.integrationPhase', phase.toLowerCase() - args '--nogui' + configureFromForgeRun(delegate, 'runServer') } } def surfaceIntegrationFreshProcess = createSurfaceProcess('Fresh', prepareSurfaceIntegrationTest) -useForge14Runtime(surfaceIntegrationFreshProcess) surfaceIntegrationFreshProcess.doLast { File marker = new File(surfaceIntegrationRunDirectory, 'surface-integration-world/surfaceprobe-integration.properties') @@ -453,7 +536,6 @@ surfaceIntegrationFreshProcess.doLast { assertRuntimeLogsClean(surfaceIntegrationRunDirectory, 'surface integration fresh phase') } def surfaceIntegrationReloadProcess = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) -useForge14Runtime(surfaceIntegrationReloadProcess) surfaceIntegrationReloadProcess.doLast { assertRuntimeLogsClean(surfaceIntegrationRunDirectory, 'surface integration reload phase') } @@ -468,6 +550,10 @@ task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { if (result.getProperty('reload_verified') != 'true') { throw new GradleException("Surface integration reload was not verified: ${marker}") } + if (!(result.getProperty('dynamic_fluid_placements') ?: '0').isInteger() + || result.getProperty('dynamic_fluid_placements').toInteger() <= 0) { + throw new GradleException("Dynamic fluid-deposit probe did not place any blocks: ${marker}") + } logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) } @@ -475,233 +561,198 @@ task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { check.dependsOn surfaceIntegrationTest -// Release qualification must also exercise the reobfuscated jar through -// Forge's real packaged-mod discovery. Supply the official 1.12.2 dedicated -// server jar and an exact Forge 14 library root; no development output or -// mapped Forge jar is placed on this process's classpath. -def packagedForgeRunDirectory = file("${buildDir}/packaged-forge-runtime-run") -def packagedMinecraftServer = { - if (!project.hasProperty('packagedMinecraftServerJar')) { - throw new GradleException('packagedMinecraftServerJar is required for packagedForgeRuntimeTest') - } - File server = file(project.property('packagedMinecraftServerJar')) - if (!server.isFile()) { - throw new GradleException("Minecraft 1.12.2 server jar is missing: ${server}") - } - server +def migrationIntegrationClasses = file("${buildDir}/migration-integration-fixture/classes") +task compileMigrationIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/migrationIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = migrationIntegrationClasses + options.encoding = 'UTF-8' } -def packagedForgeLibraries = { - if (!project.hasProperty('packagedForgeLibrariesRoot')) { - throw new GradleException('packagedForgeLibrariesRoot is required for packagedForgeRuntimeTest') - } - File libraries = file(project.property('packagedForgeLibrariesRoot')) - if (!libraries.isDirectory()) { - throw new GradleException("Forge 14 library root is missing: ${libraries}") - } - libraries + +task migrationIntegrationTestModJar(type: Jar, dependsOn: compileMigrationIntegrationTestMod) { + archiveFileName = 'migrationprobe.jar' + destinationDirectory = file("${buildDir}/migration-integration-fixture") + from migrationIntegrationClasses + from 'src/migrationIntegrationTest/resources' } -def packagedForgeRuntimeLayout = { - File forge = forge14UniversalRuntime() - File server = packagedMinecraftServer() - File libraries = packagedForgeLibraries() - java.util.jar.JarFile runtime = new java.util.jar.JarFile(forge) - String declared - try { - declared = runtime.manifest.mainAttributes.getValue('Class-Path') - } finally { - runtime.close() - } - if (declared == null || declared.trim().isEmpty()) { - throw new GradleException("Forge 14 runtime has no Class-Path manifest entry: ${forge}") + +if (project.hasProperty('migrationRunDir')) { + def migrationRunDirectory = file(project.property('migrationRunDir')) + task prepareMigrationIntegrationRun(dependsOn: migrationIntegrationTestModJar) { + doLast { + copy { from migrationIntegrationTestModJar.archiveFile; into new File(migrationRunDirectory, 'mods') } + new File(migrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + File serverProperties = new File(migrationRunDirectory, 'server.properties') + Properties values = new Properties() + if (serverProperties.isFile()) serverProperties.withInputStream { values.load(it) } + values.setProperty('spawn-animals', 'false') + values.setProperty('spawn-monsters', 'false') + serverProperties.withOutputStream { values.store(it, 'OreSpawn migration qualification') } + } } - def dependencies = declared.trim().split(/\s+/).findAll { String entry -> - entry != "minecraft_server.${minecraft_version}.jar" - }.collect { String entry -> - String relative = entry.startsWith('libraries/') ? entry.substring('libraries/'.length()) : entry - File dependency = new File(libraries, relative) - if (!dependency.isFile()) { - throw new GradleException("Forge 14 packaged-runtime dependency is missing: ${dependency}") + task migrationIntegrationProcess(type: JavaExec, dependsOn: prepareMigrationIntegrationRun) { + group = 'verification' + workingDir migrationRunDirectory + systemProperty 'orespawn.migrationFamily', project.findProperty('migrationFamily') ?: 'unspecified' + systemProperty 'orespawn.migrationPhase', project.findProperty('migrationPhase') ?: 'fresh' + configureFromForgeRun(delegate, 'runServer') + doLast { + String phase = project.findProperty('migrationPhase') ?: 'fresh' + File marker = new File(migrationRunDirectory, 'world/orespawn4-migration-probe.properties') + if (!marker.isFile()) throw new GradleException("Migration probe did not produce ${marker}") + Properties values = new Properties(); marker.withInputStream { values.load(it) } + if (values.getProperty("${phase}_complete") != 'true') { + throw new GradleException("Migration ${phase} phase did not complete: ${marker}") + } + File latest = new File(migrationRunDirectory, 'logs/latest.log') + if (latest.isFile() && (latest.text.contains('Migration audit failed') + || latest.text.contains('Encountered an unexpected exception'))) { + throw new GradleException("Migration ${phase} phase logged a server failure: ${latest}") + } } - [source: dependency, relative: relative] } - [forge: forge, server: server, dependencies: dependencies] } -task preparePackagedForgeRuntimeTest(dependsOn: [verifyPublishedJarRuntimeContract, - 'reobfPackagedSurfaceIntegrationTestModJar']) { - group = 'verification' +def legacyMineralogyMigrationRunDirectory = file("${buildDir}/legacy-mineralogy-migration-run") +def legacyMineralogyMigrationArchive = new File(legacyFixtureWorlds, + 'sylvester-era-trio-source-v2.zip') + +task prepareLegacyMineralogyMigrationRun(dependsOn: [migrationIntegrationTestModJar, + verifyLegacyFixtures]) { doLast { - delete packagedForgeRunDirectory - def runtime = packagedForgeRuntimeLayout() - copy { from runtime.forge; into packagedForgeRunDirectory } + delete legacyMineralogyMigrationRunDirectory + copy { from zipTree(legacyMineralogyMigrationArchive); into legacyMineralogyMigrationRunDirectory } + File mods = new File(legacyMineralogyMigrationRunDirectory, 'mods') + mods.mkdirs() copy { - from runtime.server - into packagedForgeRunDirectory - rename { "minecraft_server.${minecraft_version}.jar" } + from new File(legacyFixtureArtifacts, 'BaseMetals_1.10.2-2.4.0.11.jar') + from new File(legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') + from migrationIntegrationTestModJar.archiveFile + into mods } - runtime.dependencies.each { Map dependency -> - File destination = new File(packagedForgeRunDirectory, - "libraries/${dependency.relative}").parentFile - copy { from dependency.source; into destination } + new File(legacyMineralogyMigrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def configureLegacyMineralogyMigrationProcess = { JavaExec process, String phase -> + process.group = 'verification' + process.workingDir legacyMineralogyMigrationRunDirectory + process.systemProperty 'orespawn.migrationFamily', 'legacy-mineralogy-cyano' + process.systemProperty 'orespawn.migrationPhase', phase + configureFromForgeRun(process, 'runServer') + process.doLast { + File marker = new File(legacyMineralogyMigrationRunDirectory, + 'world/orespawn4-migration-probe.properties') + if (!marker.isFile()) throw new GradleException("Legacy Mineralogy migration did not produce ${marker}") + Properties values = new Properties(); marker.withInputStream { values.load(it) } + if (values.getProperty("${phase}_complete") != 'true') { + throw new GradleException("Legacy Mineralogy migration ${phase} phase did not complete") } - File mods = new File(packagedForgeRunDirectory, 'mods') - mods.mkdirs() - copy { from jar.archivePath; into mods } - copy { from packagedSurfaceIntegrationTestModJar.archivePath; into mods } - new File(packagedForgeRunDirectory, 'server.properties').setText('''\ -level-name=surface-integration-world -level-seed=zsjpxah -level-type=default -online-mode=false -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(packagedForgeRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') } } -def createPackagedForgeProcess = { String phase, Object dependency -> - ByteArrayOutputStream console = new ByteArrayOutputStream() - File consoleFile = new File(packagedForgeRunDirectory, - "packaged-forge-${phase.toLowerCase()}-console.txt") - task("packagedForgeRuntime${phase}Process", type: Exec, dependsOn: dependency) { - group = 'verification' - workingDir packagedForgeRunDirectory - doFirst { - console.reset() - File javaExecutable = new File(System.getProperty('java.home'), - "bin/java${System.properties['os.name'].toLowerCase().contains('windows') ? '.exe' : ''}") - File forge = new File(packagedForgeRunDirectory, forge14UniversalRuntime().name) - commandLine javaExecutable, - '-Dforge.logging.console.level=info', - "-Dsurfaceprobe.integrationPhase=${phase.toLowerCase()}", - '-jar', forge, 'nogui' - standardOutput = console - errorOutput = console - } - doLast { - consoleFile.setText(console.toString('UTF-8'), 'UTF-8') - } - } -} +task legacyMineralogyMigrationFresh(type: JavaExec, dependsOn: prepareLegacyMineralogyMigrationRun) +configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationFresh, 'fresh') -def packagedForgeFreshProcess = createPackagedForgeProcess('Fresh', preparePackagedForgeRuntimeTest) -packagedForgeFreshProcess.doLast { - File marker = new File(packagedForgeRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - if (!marker.isFile()) { - throw new GradleException("Packaged Forge fresh marker is missing: ${marker}") - } - assertRuntimeLogsClean(packagedForgeRunDirectory, 'packaged Forge fresh phase') -} -def packagedForgeReloadProcess = createPackagedForgeProcess('Reload', packagedForgeFreshProcess) -packagedForgeReloadProcess.doLast { - assertRuntimeLogsClean(packagedForgeRunDirectory, 'packaged Forge reload phase') -} +task legacyMineralogyMigrationReload(type: JavaExec, dependsOn: legacyMineralogyMigrationFresh) +configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationReload, 'reload') -task packagedForgeRuntimeTest(dependsOn: packagedForgeReloadProcess) { +task legacyMineralogyMigrationTest(dependsOn: legacyMineralogyMigrationReload) { group = 'verification' - description = 'Creates and reloads a world using the reobfuscated OreSpawn jar under the real Forge 14 launcher.' + description = 'Proves an existing Mineralogy 3 world is pinned to Cyano settings across reload.' doLast { - File marker = new File(packagedForgeRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - Properties result = new Properties() - marker.withInputStream { result.load(it) } - if (result.getProperty('reload_verified') != 'true') { - throw new GradleException("Packaged Forge reload was not verified: ${marker}") + File marker = new File(legacyMineralogyMigrationRunDirectory, + 'world/orespawn4-migration-probe.properties') + Properties values = new Properties(); marker.withInputStream { values.load(it) } + ['fresh_complete', 'reload_complete', 'legacy_mineralogy_config_sha256', + 'legacy_mineralogy_world_profile_sha256'].each { key -> + if (!values.getProperty(key)) throw new GradleException("Missing legacy Mineralogy evidence ${key}") } - logger.lifecycle('Packaged Forge jar created and reloaded {} dimensions with {} audited columns each', - result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) + logger.lifecycle('Existing Mineralogy 3 world retained Cyano profile and exact config/profile hashes across reload') } } -// The packaged launcher needs two machine-local inputs that cannot be checked -// into the repository. When callers supply them, make the real-runtime gate a -// mandatory part of check; otherwise the focused jar-contract verification -// still protects the manifest and embedded access-transformer contract. -if (project.hasProperty('packagedMinecraftServerJar') - && project.hasProperty('packagedForgeLibrariesRoot')) { - check.dependsOn packagedForgeRuntimeTest +// The Forge 1.10 migration task above is retained only as source-history +// reference. Forge 1.12 runs the two target-native lineage gates below. + +def os1AbiFixtureClasses = file("${buildDir}/legacy-abi/os1/classes") +def os3AbiFixtureClasses = file("${buildDir}/legacy-abi/os3/classes") + +task compileOs1AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { + source fileTree('src/os1AbiFixture/java') + classpath = files(sourceSets.main.compileClasspath, + new File(legacyFixtureArtifacts, 'OreSpawn_1.10.2-1.1.0.jar')) + destinationDirectory = os1AbiFixtureClasses + options.encoding = 'UTF-8' } -def migrationIntegrationClasses = file("${buildDir}/migration-integration-fixture/classes") -task compileMigrationIntegrationTestMod(type: JavaCompile, dependsOn: classes) { - source fileTree('src/migrationIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDir = migrationIntegrationClasses - sourceCompatibility = '1.8' - targetCompatibility = '1.8' +task os1AbiFixtureJar(type: Jar, dependsOn: compileOs1AbiFixture) { + archiveFileName = 'os1abiprobe.jar' + destinationDirectory = file("${buildDir}/legacy-abi/os1") + from os1AbiFixtureClasses + from 'src/os1AbiFixture/resources' +} + +task compileOs3AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { + source fileTree('src/os3AbiFixture/java') + classpath = files(sourceSets.main.compileClasspath, + new File(legacyFixtureArtifacts, 'OreSpawn-1.10.2-3.2.2.104.jar')) + destinationDirectory = os3AbiFixtureClasses options.encoding = 'UTF-8' } -task migrationIntegrationTestModJar(type: Jar, dependsOn: compileMigrationIntegrationTestMod) { - archiveName = 'migrationprobe.jar' - destinationDir = file("${buildDir}/migration-integration-fixture") - from migrationIntegrationClasses - from 'src/migrationIntegrationTest/resources' +task os3AbiFixtureJar(type: Jar, dependsOn: compileOs3AbiFixture) { + archiveFileName = 'os3abiprobe.jar' + destinationDirectory = file("${buildDir}/legacy-abi/os3") + from os3AbiFixtureClasses + from 'src/os3AbiFixture/resources' } -if (project.hasProperty('migrationRunDir')) { - def migrationRunDirectory = file(project.property('migrationRunDir')) - def migrationMainOutput = file("${buildDir}/migration-integration-fixture/orespawn-main") - task prepareMigrationIntegrationRun(dependsOn: migrationIntegrationTestModJar) { +def configureLegacyAbiProcess = { String generation, Task fixtureJar, String markerName -> + File runDirectory = file("${buildDir}/legacy-abi/${generation}/run") + Task prepare = tasks.create("prepare${generation.capitalize()}AbiRun", Copy) { + dependsOn fixtureJar + into new File(runDirectory, 'mods') + from fixtureJar.archiveFile + doFirst { project.delete(runDirectory) } doLast { - delete migrationMainOutput - migrationMainOutput.mkdirs() - copy { from sourceSets.main.output; into migrationMainOutput } - copy { from migrationIntegrationTestModJar.archivePath; into new File(migrationRunDirectory, 'mods') } - new File(migrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'server.properties').setText( + 'eula=true\nonline-mode=false\nlevel-name=world\nlevel-seed=zsjpxah\n' + + 'spawn-animals=false\nspawn-monsters=false\n', 'UTF-8') } } - task migrationIntegrationProcess(type: JavaExec, - dependsOn: [prepareMigrationIntegrationRun, 'createSrgToMcp']) { + tasks.create("${generation}AbiIntegrationProcess", JavaExec) { group = 'verification' - main = 'net.minecraftforge.legacydev.MainServer' - classpath = files(sourceSets.main.runtimeClasspath, legacyDevRuntime) - workingDir migrationRunDirectory - environment 'mainClass', 'net.minecraft.launchwrapper.Launch' - environment 'MCP_TO_SRG', file("${buildDir}/createSrgToMcp/output.srg").absolutePath - environment 'MOD_CLASSES', migrationMainOutput.absolutePath - environment 'tweakClass', 'net.minecraftforge.fml.common.launcher.FMLServerTweaker' - systemProperty 'forge.logging.console.level', 'info' - systemProperty 'orespawn.migrationFamily', project.findProperty('migrationFamily') ?: 'unspecified' - systemProperty 'orespawn.migrationPhase', project.findProperty('migrationPhase') ?: 'fresh' - if ((project.findProperty('migrationAllowMissingMappings') ?: 'false') == 'true') { - systemProperty 'fml.queryResult', 'confirm' - } - args '--nogui' - doLast { - def phase = project.findProperty('migrationPhase') ?: 'fresh' - def marker = new File(migrationRunDirectory, - 'world/orespawn4-migration-probe.properties') - if (!marker.isFile()) { - throw new GradleException("Migration probe did not produce ${marker}") - } - def values = new Properties() - marker.withInputStream { values.load(it) } - if (values.getProperty("${phase}_complete") != 'true') { - throw new GradleException("Migration ${phase} phase did not complete: ${marker}") - } - def latest = new File(migrationRunDirectory, 'logs/latest.log') - assertRuntimeLogsClean(migrationRunDirectory, "migration ${phase} phase") - if (latest.isFile() - && (project.findProperty('migrationAllowMissingMappings') ?: 'false') == 'true') { - def protectedMissing = latest.text =~ /(?m)^(?:Missing (?:basemetals|orespawn):|\s+(?:basemetals|mmdlib|orespawn):)/ - if (protectedMissing.find()) { - throw new GradleException("Protected Base Metals/OreSpawn mapping is missing: ${protectedMissing.group()}") - } - } - } - } - useForge14Runtime(migrationIntegrationProcess) -} - -// Two sealed existing-world upgrades exercise the distinct Mineralogy configs -// that can reach Forge 1.12: a carried 1.10 file and the native 1.12 file. -def legacyMineralogyArchive = file("${rootDir}/../migration-fixtures/sources/worlds/os3-331-default-source.zip") -def legacyMineralogyJar = file("${rootDir}/../migration-fixtures/sources/artifacts/Mineralogy-1.12.2-3.8.0.53.jar") + dependsOn prepare + workingDir runDirectory + configureFromForgeRun(delegate, 'runServer') + doLast { + File marker = new File(runDirectory, "world/${markerName}") + if (!marker.isFile() || !marker.text.contains('registered=true')) { + throw new GradleException("${generation.toUpperCase()} binary ABI probe did not complete: ${marker}") + } + File latest = new File(runDirectory, 'logs/latest.log') + if (latest.isFile() && latest.text.contains('Encountered an unexpected exception')) { + throw new GradleException("${generation.toUpperCase()} binary ABI probe logged a server failure") + } + } + } +} + +configureLegacyAbiProcess('os1', os1AbiFixtureJar, 'os1-abi-probe.properties') +configureLegacyAbiProcess('os3', os3AbiFixtureJar, 'os3-abi-probe.properties') + +task legacyAbiIntegrationTest { + group = 'verification' + dependsOn os1AbiIntegrationProcess, os3AbiIntegrationProcess +} + +// Forge 1.12's published OS3 ABI fixture remains an opt-in external gate; the +// 1.10 OS1/OS3 compiled probes are not part of this target's standard check. + +def legacyMineralogyArchive = new File(legacyFixtureWorlds, 'os3-331-default-source.zip') +def legacyMineralogyJar = legacyMineralogy112OracleJar def legacyMineralogyGates = [] [ '110': [version: '3.3.8.26', config: '''\ @@ -721,92 +772,368 @@ world-gen { } '''] ].each { String lineage, Map fixture -> - String label = lineage == '110' ? '110' : '112' + String label = lineage File runDirectory = file("${buildDir}/legacy-mineralogy-${lineage}-run") - File mainOutput = file("${buildDir}/legacy-mineralogy-${lineage}-fixture/orespawn-main") - def prepareTask = task("prepareLegacyMineralogy${label}Run", - dependsOn: migrationIntegrationTestModJar) { + Task prepareTask = tasks.create("prepareLegacyMineralogy${label}Run") { + dependsOn migrationIntegrationTestModJar, verifyLegacyFixtures doLast { - if (!legacyMineralogyArchive.isFile() || !legacyMineralogyJar.isFile()) { - throw new GradleException('Sealed legacy Mineralogy fixtures are missing') - } delete runDirectory - delete mainOutput copy { from zipTree(legacyMineralogyArchive); into runDirectory } - mainOutput.mkdirs() - copy { from sourceSets.main.output; into mainOutput } - File mods = new File(runDirectory, 'mods'); mods.mkdirs() - copy { from migrationIntegrationTestModJar.archivePath; from legacyMineralogyJar; into mods } + File mods = new File(runDirectory, 'mods') + mods.mkdirs() + copy { from migrationIntegrationTestModJar.archiveFile; from legacyMineralogyJar; into mods } File config = new File(runDirectory, 'config/mineralogy.cfg') - config.parentFile.mkdirs(); config.setText(fixture.config as String, 'UTF-8') + config.parentFile.mkdirs() + config.setText(fixture.config as String, 'UTF-8') new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - project.javaexec { - main = 'zone.moddev.mc.orespawn.migrationtest.LegacyMineralogyMetadataFixture' - classpath = files(migrationIntegrationClasses, sourceSets.main.runtimeClasspath) - args new File(runDirectory, 'world').absolutePath, fixture.version - } } } + Task seedMetadataTask = tasks.create("seedLegacyMineralogy${label}Metadata", JavaExec) { + group = 'verification' + dependsOn prepareTask + javaLauncher = java8Launcher + mainClass = 'zone.moddev.mc.orespawn.migrationtest.LegacyMineralogyMetadataFixture' + classpath = files(migrationIntegrationClasses, sourceSets.main.runtimeClasspath) + args new File(runDirectory, 'world').absolutePath, fixture.version + } def createLegacyMineralogyProcess = { String phase, Object dependency -> - def process = task("legacyMineralogy${label}${phase.capitalize()}", type: JavaExec, - dependsOn: [dependency, 'createSrgToMcp']) { + Task process = tasks.create("legacyMineralogy${label}${phase.capitalize()}", JavaExec) { group = 'verification' - main = 'net.minecraftforge.legacydev.MainServer' - classpath = files(sourceSets.main.runtimeClasspath, legacyDevRuntime) + dependsOn dependency workingDir runDirectory - environment 'mainClass', 'net.minecraft.launchwrapper.Launch' - environment 'MCP_TO_SRG', file("${buildDir}/createSrgToMcp/output.srg").absolutePath - environment 'MOD_CLASSES', mainOutput.absolutePath - environment 'tweakClass', 'net.minecraftforge.fml.common.launcher.FMLServerTweaker' systemProperty 'forge.logging.console.level', 'info' systemProperty 'orespawn.migrationFamily', "legacy-mineralogy-${lineage}" systemProperty 'orespawn.migrationPhase', phase - args '--nogui' + configureFromForgeRun(delegate, 'runServer') doLast { File marker = new File(runDirectory, 'world/orespawn4-migration-probe.properties') if (!marker.isFile()) { throw new GradleException("Legacy Mineralogy ${lineage} ${phase} marker is missing") } - Properties values = new Properties(); marker.withInputStream { values.load(it) } + Properties values = new Properties() + marker.withInputStream { values.load(it) } if (values.getProperty("${phase}_complete") != 'true') { throw new GradleException("Legacy Mineralogy ${lineage} ${phase} did not complete") } - assertRuntimeLogsClean(runDirectory, "legacy Mineralogy ${lineage} ${phase} phase") + assertRuntimeLogsClean(runDirectory, + "legacy Mineralogy ${lineage} ${phase} phase") } } - useForge14Runtime(process) process } - def freshTask = createLegacyMineralogyProcess('fresh', prepareTask) - def reloadTask = createLegacyMineralogyProcess('reload', freshTask) - def gate = task("legacyMineralogy${label}MigrationTest", dependsOn: reloadTask) { + Task freshTask = createLegacyMineralogyProcess('fresh', seedMetadataTask) + Task reloadTask = createLegacyMineralogyProcess('reload', freshTask) + Task gate = tasks.create("legacyMineralogy${label}MigrationTest") { group = 'verification' - description = "Proves Mineralogy ${lineage} settings remain exact across an OreSpawn 4 upgrade and reload." + description = "Proves Mineralogy ${lineage} settings remain exact across upgrade and reload." + dependsOn reloadTask } legacyMineralogyGates.add(gate) } -check.dependsOn legacyMineralogyGates +tasks.named('check') { + dependsOn legacyMineralogyGates +} + +def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") +task compileClientIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = clientIntegrationClasses + options.encoding = 'UTF-8' +} + +task clientIntegrationTestModJar(type: Jar, dependsOn: compileClientIntegrationTestMod) { + archiveFileName = 'clientprobe.jar' + destinationDirectory = file("${buildDir}/client-integration-fixture") + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} + +def packagedClientProbeJar = renamer.classes(tasks.named('clientIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('client-integration-fixture/clientprobe-reobf.jar') +} -if (project.hasProperty('os3AbiFixtureJar')) { - def os3AbiFixture = file(project.property('os3AbiFixtureJar')) - def os3AbiRunDirectory = file(project.findProperty('os3AbiRunDir') - ?: "${buildDir}/os3-abi-integration-run") - def os3AbiMainOutput = file("${buildDir}/os3-abi-integration-fixture/orespawn-main") - task prepareOs3AbiCompatibilityRun(dependsOn: classes) { +tasks.register('preparePackagedClientProbe') { + group = 'verification' + description = 'Builds the reobfuscated client probe used only with a disposable packaged Forge runtime.' + dependsOn packagedClientProbeJar +} + +def clientIntegrationRunDirectory = file("${buildDir}/client-integration-run") +task prepareClientIntegrationTest(dependsOn: clientIntegrationTestModJar) { + doLast { + delete clientIntegrationRunDirectory + clientIntegrationRunDirectory.mkdirs() + copy { + from clientIntegrationTestModJar.archiveFile + into new File(clientIntegrationRunDirectory, 'mods') + } + new File(clientIntegrationRunDirectory, 'options.txt').setText( + 'fullscreen:false\nforceUnicodeFont:false\nguiScale:2\n' + + 'renderDistance:4\nshowSubtitles:false\n', 'UTF-8') + } +} + +task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegrationTest) { + group = 'verification' + workingDir clientIntegrationRunDirectory + systemProperty 'clientprobe.enabled', 'true' + configureFromForgeRun(delegate, 'runClient') + doLast { + File marker = new File(clientIntegrationRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException("Client integration completion marker is missing: ${marker}") + } + Properties result = new Properties(); marker.withInputStream { result.load(it) } + if (result.getProperty('reload_rendered') != 'true' + || result.getProperty('world_settings_opened') != 'true' + || result.getProperty('long_editor_roundtrip') != 'true' + || Integer.parseInt(result.getProperty('editor_routes', '0')) < 5) { + throw new GradleException("Client integration result is incomplete: ${marker}") + } + assertRuntimeLogsClean(clientIntegrationRunDirectory, 'client integration fresh/reload phases') + } +} + +task syncForge12EclipseIntegrationLaunches { + group = 'ide' + doLast { + def writeGradleLaunch = { String fileName, String displayName, String arguments -> + File launch = file(fileName) + String escapedArguments = arguments + .replace('&', '&') + .replace('"', '"') + launch.setText(""" + + + + + + + + +""", 'UTF-8') + } + + writeGradleLaunch('OreSpawn_Surface_FreshReload.launch', + 'OreSpawn surface fresh/reload gate', + 'surfaceIntegrationTest --offline --no-daemon') + writeGradleLaunch('OreSpawn_Migration_Fresh.launch', + 'OreSpawn migration fresh gate', + 'migrationIntegrationProcess --offline --no-daemon ' + + '-PmigrationRunDir=build/eclipse-migration-run ' + + '-PmigrationFamily=eclipse-manual -PmigrationPhase=fresh') + writeGradleLaunch('OreSpawn_Migration_Reload.launch', + 'OreSpawn migration reload gate', + 'migrationIntegrationProcess --offline --no-daemon ' + + '-PmigrationRunDir=build/eclipse-migration-run ' + + '-PmigrationFamily=eclipse-manual -PmigrationPhase=reload') + writeGradleLaunch('OreSpawn_Client_FreshReload.launch', + 'OreSpawn client world and editor fresh/reload gate', + 'clientIntegrationProcess --offline --no-daemon') + } +} + +tasks.named('genEclipseRuns') { + finalizedBy syncForge12EclipseIntegrationLaunches +} + +if (project.hasProperty('benchmarkRunDir')) { + def benchmarkRunDirectory = file(project.property('benchmarkRunDir')) + task prepareWorldgenBenchmark { doLast { - if (!os3AbiFixture.isFile()) { - throw new GradleException("Published-API fixture jar is missing: ${os3AbiFixture}") - } - delete os3AbiRunDirectory - delete os3AbiMainOutput - os3AbiRunDirectory.mkdirs() - copy { from sourceSets.main.output; into os3AbiMainOutput } - copy { from os3AbiFixture; into new File(os3AbiRunDirectory, 'mods') } - new File(os3AbiRunDirectory, 'server.properties').setText('''\ -level-name=os3-abi-world + delete benchmarkRunDirectory + benchmarkRunDirectory.mkdirs() + new File(benchmarkRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(benchmarkRunDirectory, 'server.properties').setText( + 'online-mode=false\nlevel-name=world\nlevel-type=default\n' + + "level-seed=${project.findProperty('benchmarkSeed') ?: '-4965128775892001975'}\n" + + 'generate-structures=false\nspawn-animals=false\nspawn-monsters=false\n' + + 'max-tick-time=-1\n', 'UTF-8') + } + } +task benchmarkIntegrationProcess(type: JavaExec, dependsOn: prepareWorldgenBenchmark) { + group = 'verification' + workingDir benchmarkRunDirectory + systemProperty 'orespawn.worldgenBenchmarkMode', project.findProperty('benchmarkMode') ?: 'sky' + systemProperty 'orespawn.worldgenBenchmarkRadius', project.findProperty('benchmarkRadius') ?: '4' + systemProperty 'orespawn.worldgenBenchmarkRepetitions', project.findProperty('benchmarkRepetitions') ?: '3' + systemProperty 'orespawn.worldgenBenchmarkCenterX', project.findProperty('benchmarkCenterX') ?: '256' + systemProperty 'orespawn.worldgenBenchmarkCenterZ', project.findProperty('benchmarkCenterZ') ?: '256' + systemProperty 'orespawn.worldgenBenchmarkCenterStep', project.findProperty('benchmarkCenterStep') ?: '64' + systemProperty 'orespawn.worldgenBenchmarkStopServer', 'true' + systemProperty 'orespawn.worldgenBenchmarkVanillaOres', + project.findProperty('benchmarkVanillaOres') ?: 'false' + systemProperty 'orespawn.worldgenBenchmarkOreAudit', + project.findProperty('benchmarkOreAudit') ?: 'false' + if (project.hasProperty('benchmarkBlockAudit')) { + systemProperty 'orespawn.worldgenBenchmarkBlockAudit', project.property('benchmarkBlockAudit') + } + configureFromForgeRun(delegate, 'runServer') + doLast { + File latest = new File(benchmarkRunDirectory, 'logs/latest.log') + String mode = project.findProperty('benchmarkMode') ?: 'sky' + if (!latest.isFile() || !latest.text.contains("ORESPAWN_BENCHMARK summary mode=${mode}")) { + throw new GradleException("Missing ${mode} worldgen benchmark summary: ${latest}") + } + assertRuntimeLogsClean(benchmarkRunDirectory, "${mode} worldgen benchmark") + } + } +} + +tasks.named('processResources', ProcessResources) { + filteringCharset = 'UTF-8' + inputs.property('version', project.version) + inputs.property('mcversion', project.minecraft_version) + + filesMatching('mcmod.info') { + expand version: project.version, mcversion: project.minecraft_version + } + from('docs/AGENTS.md') { + into '' + rename { 'AGENTS.md' } + } + from('docs') { + into 'META-INF/orespawn/docs' + } + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) +} + +def prepareEclipseResources = tasks.register('prepareEclipseResources') { + group = 'ide' + description = 'Copies Gradle-processed production resources into Eclipse merged output.' + dependsOn tasks.named('processResources') + doLast { + // Do not declare bin/main as a Gradle-owned output. Eclipse owns that + // directory and ForgeGradle inspects it while generating launches. + project.copy { + from(layout.buildDirectory.dir('resources/main')) + into(layout.projectDirectory.dir('bin/main')) + } + } +} + +tasks.named('jar', Jar) { + archiveClassifier = 'deobf' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + manifest { + attributes([ + 'Specification-Title' : 'OreSpawn', + 'Specification-Vendor' : 'SkyBlade1978', + 'Specification-Version' : '1', + 'Implementation-Title' : base.archivesName.get(), + 'Implementation-Version' : project.version, + 'Implementation-Vendor' : 'SkyBlade1978', + 'OreSpawn-API-Version' : '1', + 'FMLAT' : 'accesstransformer.cfg', + 'Maven-Artifact' : "${project.group}:${base.archivesName.get()}:${project.version}", + 'Built-On-Java' : '8', + 'Built-On' : "${project.minecraft_version}-${project.forge_version}" + ]) + } +} + +def releaseJar = renamer.classes(tasks.named('jar', Jar)) { + map.from minecraft.dependency.toSrgFile + archiveClassifier = null + accessTransformers = true + output = layout.buildDirectory.file("libs/OreSpawn-${project.version}.jar") +} + +// Release qualification also exercises the reobfuscated jar through Forge's +// packaged launcher. The official server jar and its library tree are +// machine-local inputs and are never copied into source control or artifacts. +def forge14UniversalRuntime = { + File runtime = new File(gradle.gradleUserHomeDir, + "caches/forge_gradle/maven_downloader/net/minecraftforge/forge/" + + "${minecraft_version}-${forge_version}/" + + "forge-${minecraft_version}-${forge_version}-universal.jar") + if (!runtime.isFile()) { + throw new GradleException("Forge 14 universal runtime is missing: ${runtime}") + } + runtime +} + +def packagedForgeRunDirectory = file("${buildDir}/packaged-forge-runtime-run") +def packagedMinecraftServer = { + if (!project.hasProperty('packagedMinecraftServerJar')) { + throw new GradleException('packagedMinecraftServerJar is required for packagedForgeRuntimeTest') + } + File server = file(project.property('packagedMinecraftServerJar')) + if (!server.isFile()) { + throw new GradleException("Minecraft 1.12.2 server jar is missing: ${server}") + } + server +} +def packagedForgeLibraries = { + if (!project.hasProperty('packagedForgeLibrariesRoot')) { + throw new GradleException('packagedForgeLibrariesRoot is required for packagedForgeRuntimeTest') + } + File libraries = file(project.property('packagedForgeLibrariesRoot')) + if (!libraries.isDirectory()) { + throw new GradleException("Forge 14 library root is missing: ${libraries}") + } + libraries +} +def packagedForgeRuntimeLayout = { + File forge = forge14UniversalRuntime() + File server = packagedMinecraftServer() + File libraries = packagedForgeLibraries() + java.util.jar.JarFile runtime = new java.util.jar.JarFile(forge) + String declared + try { + declared = runtime.manifest.mainAttributes.getValue('Class-Path') + } finally { + runtime.close() + } + if (declared == null || declared.trim().isEmpty()) { + throw new GradleException("Forge 14 runtime has no Class-Path manifest entry: ${forge}") + } + def dependencies = declared.trim().split(/\s+/).findAll { String entry -> + entry != "minecraft_server.${minecraft_version}.jar" + }.collect { String entry -> + String relative = entry.startsWith('libraries/') + ? entry.substring('libraries/'.length()) : entry + File dependency = new File(libraries, relative) + if (!dependency.isFile()) { + throw new GradleException("Forge 14 packaged-runtime dependency is missing: ${dependency}") + } + [source: dependency, relative: relative] + } + [forge: forge, server: server, dependencies: dependencies] +} + +tasks.register('preparePackagedForgeRuntimeTest') { + group = 'verification' + dependsOn releaseJar + dependsOn packagedSurfaceIntegrationTestModJar + doLast { + delete packagedForgeRunDirectory + def runtime = packagedForgeRuntimeLayout() + copy { from runtime.forge; into packagedForgeRunDirectory } + copy { + from runtime.server + into packagedForgeRunDirectory + rename { "minecraft_server.${minecraft_version}.jar" } + } + runtime.dependencies.each { Map dependency -> + File destination = new File(packagedForgeRunDirectory, + "libraries/${dependency.relative}").parentFile + copy { from dependency.source; into destination } + } + File mods = new File(packagedForgeRunDirectory, 'mods') + mods.mkdirs() + copy { from layout.buildDirectory.file("libs/OreSpawn-${project.version}.jar"); into mods } + copy { + from layout.buildDirectory.file('surface-integration-fixture/surfaceprobe-reobf.jar') + into mods + } + new File(packagedForgeRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world level-seed=zsjpxah level-type=default online-mode=false @@ -815,123 +1142,678 @@ generate-structures=false spawn-protection=0 max-tick-time=-1 ''', 'UTF-8') - new File(os3AbiRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } + new File(packagedForgeRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') } - task os3AbiCompatibilityProcess(type: JavaExec, - dependsOn: [prepareOs3AbiCompatibilityRun, 'createSrgToMcp']) { +} + +def createPackagedForgeProcess = { String phase, Object dependency -> + ByteArrayOutputStream console = new ByteArrayOutputStream() + File consoleFile = new File(packagedForgeRunDirectory, + "packaged-forge-${phase.toLowerCase()}-console.txt") + tasks.register("packagedForgeRuntime${phase}Process", Exec) { group = 'verification' - main = 'net.minecraftforge.legacydev.MainServer' - classpath = files(sourceSets.main.runtimeClasspath, legacyDevRuntime) - workingDir os3AbiRunDirectory - environment 'mainClass', 'net.minecraft.launchwrapper.Launch' - environment 'MCP_TO_SRG', file("${buildDir}/createSrgToMcp/output.srg").absolutePath - environment 'MOD_CLASSES', os3AbiMainOutput.absolutePath - environment 'tweakClass', 'net.minecraftforge.fml.common.launcher.FMLServerTweaker' - systemProperty 'forge.logging.console.level', 'info' - args '--nogui' + dependsOn dependency + workingDir packagedForgeRunDirectory + doFirst { + console.reset() + File javaExecutable = java8Launcher.get().executablePath.asFile + File forge = new File(packagedForgeRunDirectory, forge14UniversalRuntime().name) + commandLine javaExecutable, + '-Dforge.logging.console.level=info', + "-Dsurfaceprobe.integrationPhase=${phase.toLowerCase()}", + '-jar', forge, 'nogui' + standardOutput = console + errorOutput = console + } doLast { - String fixtureName = os3AbiFixture.name.toLowerCase(java.util.Locale.ROOT) - String fixtureId = fixtureName.contains('322') ? 'os3abi322' : 'os3abi331' - File marker = new File(os3AbiRunDirectory, "${fixtureId}-pass.txt") - if (!marker.isFile()) { - throw new GradleException("Published OS3 API fixture did not pass: ${marker}") + consoleFile.setText(console.toString('UTF-8'), 'UTF-8') + } + } +} + +def packagedForgeFreshProcess = createPackagedForgeProcess( + 'Fresh', tasks.named('preparePackagedForgeRuntimeTest')) +packagedForgeFreshProcess.configure { + doLast { + File marker = new File(packagedForgeRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) { + throw new GradleException("Packaged Forge fresh marker is missing: ${marker}") + } + assertRuntimeLogsClean(packagedForgeRunDirectory, 'packaged Forge fresh phase') + } +} +def packagedForgeReloadProcess = createPackagedForgeProcess('Reload', packagedForgeFreshProcess) +packagedForgeReloadProcess.configure { + doLast { + assertRuntimeLogsClean(packagedForgeRunDirectory, 'packaged Forge reload phase') + } +} + +tasks.register('packagedForgeRuntimeTest') { + group = 'verification' + description = 'Creates and reloads a world using the reobfuscated OreSpawn jar under real Forge 14.' + dependsOn packagedForgeReloadProcess + doLast { + File marker = new File(packagedForgeRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("Packaged Forge reload was not verified: ${marker}") + } + logger.lifecycle('Packaged Forge jar created and reloaded {} dimensions with {} audited columns each', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) + } +} + +if (project.hasProperty('packagedMinecraftServerJar') + && project.hasProperty('packagedForgeLibrariesRoot')) { + tasks.named('check') { + dependsOn tasks.named('packagedForgeRuntimeTest') + } +} + +def apiJar = tasks.register('apiJar', Jar) { + dependsOn tasks.named('classes') + archiveClassifier = 'api' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + from sourceSets.main.output + include 'zone/moddev/mc/orespawn/api/**' + include 'com/mcmoddev/orespawn/api/**' + include 'com/mojang/serialization/**' + include 'cyano/orespawn/**' + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-api', + 'Implementation-Version': project.version, + 'OreSpawn-API-Version' : '1' + ]) + } +} + +tasks.register('deobfJar') { + group = 'build' + description = 'Builds the local deobfuscated development jar under build/libs-dev.' + dependsOn tasks.named('jar') +} + +tasks.named('sourcesJar', Jar) { + filteringCharset = 'UTF-8' + includeEmptyDirs = false + archiveClassifier = 'sources' + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Maven-Artifact' : "${project.group}:${base.archivesName.get()}:${project.version}:sources", + 'Implementation-Title' : 'OreSpawn-sources', + 'Implementation-Version': project.version + ]) + } +} + +tasks.named('javadocJar', Jar) { + filteringCharset = 'UTF-8' + archiveClassifier = 'javadoc' + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Maven-Artifact' : "${project.group}:${base.archivesName.get()}:${project.version}:javadoc", + 'Implementation-Title' : 'OreSpawn-javadoc', + 'Implementation-Version': project.version + ]) + } +} + +['apiElements', 'runtimeElements'].each { configurationName -> + configurations.named(configurationName) { + artifacts.clear() + } + artifacts { + add(configurationName, releaseJar) + } +} + +tasks.named('assemble') { + dependsOn releaseJar + dependsOn apiJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +def expectedReleaseFiles = providers.provider { + def prefix = "${base.archivesName.get()}-${project.version}" + [ + "${prefix}.jar", + "${prefix}-sources.jar", + "${prefix}-javadoc.jar" + ] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + description = 'Validates the target-qualified release, API, schemas, reports, and publishing identity.' + + doLast { + if (project.mod_version != '4.0.8.112021') { + throw new GradleException("Unexpected OreSpawn release version: ${project.mod_version}") + } + if (project.minecraft_version != '1.12.2' + || project.forge_version != '14.23.5.2859' + || project.mapping_channel != 'stable' + || project.mapping_version != '39-1.12') { + throw new GradleException('Unexpected Minecraft, Forge, or mappings target') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '8' || project.gradle_java_version != '17') { + throw new GradleException('Unexpected dispatcher target metadata') + } + if (project.group.toString() != 'zone.moddev.mc' + || base.archivesName.get() != 'OreSpawn' + || project.curseforge_project_id != '245586') { + throw new GradleException('Unexpected Maven or CurseForge publication identity') + } + + [ + 'src/main/java/zone/moddev/mc/orespawn/OreSpawn.java', + 'src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains('4.0.8.112021')) { + throw new GradleException("Authoritative release location does not contain 4.0.8.112021: ${path}") } - File latest = new File(os3AbiRunDirectory, 'logs/latest.log') - assertRuntimeLogsClean(os3AbiRunDirectory, 'published OS3 API fixture') + } + if (!file('docs/API.md').getText('UTF-8').contains('orespawn@[4.0.6,5.0.0)')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') + .getText('UTF-8').contains('API_VERSION = 1')) { + throw new GradleException('OreSpawn API major must remain 1') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 6') + || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 5')) { + throw new GradleException('Global/world schema versions must remain 6/5') + } + def providerSchema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) + if (!(providerSchema.properties.schema_version.enum as List).contains(4)) { + throw new GradleException('Provider schema must continue to support schema version 4') } } - useForge14Runtime(os3AbiCompatibilityProcess) } -task syncForge14EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + description = 'Audits the exact three distributable jars and their release-critical contents.' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn tasks.named('assemble') + + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') } + .sort { it.name } + List actual = jars.collect { it.name } + List expected = expectedReleaseFiles.get().sort() + if (actual != expected) { + throw new GradleException("Expected exactly ${expected}, found ${actual}") + } + + jars.each { candidate -> + if (candidate.length() == 0L) { + throw new GradleException("Empty release artifact: ${candidate.name}") + } + ZipFile candidateZip = new ZipFile(candidate) + try { + candidateZip.entries().findAll { entry -> + !entry.isDirectory() + && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean containsCarriageReturn = candidateZip.getInputStream(entry).withCloseable { input -> + input.bytes.any { value -> value == 13 } + } + if (containsCarriageReturn) { + throw new GradleException( + "${candidate.name}!/${entry.name} does not use canonical LF line endings") + } + } + ['src/test/', 'src/biomeIntegrationTest/', 'src/migrationIntegrationTest/', + 'src/clientIntegrationTest/', 'src/os1AbiFixture/', 'src/os3AbiFixture/', + 'agent-notes/', 'surfaceprobe', 'migrationprobe', 'clientprobe', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/'].each { forbidden -> + if (candidateZip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden entry matching ${forbidden}") + } + } + } finally { + candidateZip.close() + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + ZipFile zip = new ZipFile(mainJar) + try { + List names = zip.entries().collect { it.name } + [ + 'mcmod.info', + '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', + 'META-INF/orespawn/docs/schemas/orespawn-global.schema.json', + 'META-INF/orespawn/docs/schemas/orespawn-world.schema.json', + 'AGENTS.md' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") + } + } + + String metadata = zip.getInputStream(zip.getEntry('mcmod.info')) + .getText(StandardCharsets.UTF_8.name()) + def parsed = new JsonSlurper().parseText(metadata) + def mod = parsed instanceof List ? parsed.first() : parsed + if (mod.modid != 'orespawn' || mod.version != project.mod_version + || mod.mcversion != project.minecraft_version) { + throw new GradleException('Packaged mcmod.info version or target is incorrect') + } + + String packagedAccessTransformer = zip.getInputStream( + zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List packagedAccessTransformerRules = packagedAccessTransformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() } + .findAll { !it.isEmpty() } + List expectedRuntimeAccessTransformerRules = [ + 'public-f net.minecraft.world.WorldProvider field_76578_c', + 'public-f net.minecraft.world.gen.ChunkGeneratorOverworld field_186001_t' + ] + if (packagedAccessTransformerRules != expectedRuntimeAccessTransformerRules) { + throw new GradleException('Packaged access transformer was not remapped to the runtime SRG rules') + } + + 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('FMLAT') != 'accesstransformer.cfg' + || manifest.getValue('Implementation-Timestamp') != null + || manifest.getValue('Timestamp') != null) { + throw new GradleException('Release manifest identity/API/FMLAT is incorrect or volatile') + } + + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = new byte[8] + zip.getInputStream(entry).withCloseable { input -> + if (input.read(header) != header.length) { + throw new GradleException("Cannot inspect bytecode header for ${entry.name}") + } + } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 52) { + throw new GradleException("${entry.name} uses Java class major ${major}, expected 52") + } + } + } finally { + zip.close() + } + + File sources = new File(libs, expectedReleaseFiles.get()[1]) + new ZipFile(sources).withCloseable { sourceZip -> + if (sourceZip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') + } + } + File javadocs = new File(libs, expectedReleaseFiles.get()[2]) + new ZipFile(javadocs).withCloseable { javadocZip -> + if (javadocZip.getEntry('index.html') == null) { + throw new GradleException('Javadoc jar is missing index.html') + } + } + } +} + +tasks.register('writeReleaseChecksums') { + group = 'verification' + description = 'Writes SHA-256 checksums for the audited release jars.' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + inputs.files(providers.provider { + expectedReleaseFiles.get().collect { name -> + layout.buildDirectory.file("libs/${name}").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' + description = 'Audits a previously built immutable release bundle before remote Maven publication.' + + doLast { + if (!preparedReleaseDir.isPresent()) { + throw new GradleException('preparedReleaseDir is required for prepared artifact publication') + } + File prepared = file(preparedReleaseDir.get()) + if (!prepared.isDirectory()) { + throw new GradleException("Prepared release directory does not exist: ${prepared}") + } + List expected = expectedReleaseFiles.get().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 must contain exactly the non-empty jars ${expected}") + } + File checksums = new File(prepared, 'SHA256SUMS') + File changelog = new File(prepared, 'CHANGELOG.txt') + if (!checksums.isFile() || !changelog.isFile()) { + throw new GradleException('Prepared release is missing SHA256SUMS or CHANGELOG.txt') + } + List actualChecksums = jars.collect { candidate -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + candidate.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()} ${candidate.name}" + }.sort() + List declaredChecksums = checksums.readLines('UTF-8') + .findAll { !it.trim().isEmpty() } + .sort() + if (actualChecksums != declaredChecksums) { + throw new GradleException('Prepared release checksums do not match the immutable jars') + } + } +} + +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 = project.group.toString() + artifactId = base.archivesName.get() + 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 { + artifact(releaseJar) + artifact(tasks.named('sourcesJar')) + artifact(tasks.named('javadocJar')) + } + 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' + } + } + scm { + connection = 'scm:git:https://github.com/MinecraftModDevelopmentMods/OreSpawn.git' + developerConnection = 'scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/OreSpawn.git' + url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} + +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + description = 'Prevents Maven publication from targeting a local or incomplete repository.' + 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') + } + String target = providers.environmentVariable('MAVEN_UPLOAD_URL').get() + if (target.startsWith('file:')) { + throw new GradleException('Maven release publication must use a remote repository') + } + } +} + +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + if (preparedReleaseDir.isPresent()) { + dependsOn tasks.named('verifyPreparedReleaseArtifacts') + } else { + dependsOn tasks.named('verifyReleaseArtifacts') + } +} + +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +// Older ForgeGradle workspaces can retain GradleStart launchers alongside the +// ForgeGradle 7 Buildship launches. Remove only those exact obsolete launch +// types; leave independently maintained launch files untouched. +def obsoleteForgeGradleEclipseLaunches = [ + 'OreSpawn_Client.launch': 'GradleStart', + 'OreSpawn_Server.launch': 'GradleStartServer' +] + +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true + } + synchronizationTasks 'isolateEclipseProductionRuns' +} + +tasks.register('configureEclipseBuildship') { group = 'ide' + description = 'Creates the Buildship project preferences used by ForgeGradle 7 imports.' doLast { - String mainOutput = new File(projectDir, 'bin/main').absolutePath - String fixtureOutput = surfaceIntegrationClasses.absolutePath - String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath - String fixtureModClasses = "${mainOutput};${mainOutput};${fixtureOutput};${fixtureResources}" - ['Client', 'Server'].each { String runName -> - File launch = file("run${runName}.launch") - if (!launch.isFile()) { - throw new GradleException("Missing generated Eclipse launch: ${launch}") - } - String text = launch.getText('UTF-8') - String expectedMain = runName == 'Client' - ? 'net.minecraftforge.legacydev.MainClient' - : 'net.minecraftforge.legacydev.MainServer' - if (!text.contains("value=\"${expectedMain}\"")) { - throw new GradleException("ForgeGradle generated an unexpected ${runName} launcher") - } - text = text.replace('value="${MC_VERSION}"', "value=\"${minecraft_version}\"") - String excludeTestKey = 'org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE' - String excludeTestAttribute = - "" - if (text.contains("key=\"${excludeTestKey}\"")) { - text = text.replaceFirst( - //, - excludeTestAttribute) - } else { - int launchHeaderEnd = text.indexOf('\n', text.indexOf(' - File launch = file("runSurfaceIntegration${phase}.launch") - String text = serverTemplate - text = text.replace( - '', - "") - text = text.replace( - "", - "") - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - launch.setText(text, 'UTF-8') - } - } -} - -task syncForge14EclipseClasspath(dependsOn: forge14RuntimeJar) { + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + Map requiredPreferences = [ + 'eclipse.preferences.version' : '1', + 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', + 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'connection.project.dir' : '', + 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'override.workspace.settings' : 'true' + ] + requiredPreferences.each { key, value -> preferences.setProperty(key, value) } + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, + 'Generated by configureEclipseBuildship; keep Eclipse and command-line caches aligned.') + } + } +} + +tasks.register('isolateEclipseProductionRuns') { group = 'ide' + description = 'Normalizes ForgeGradle 7 Eclipse launches and marks ordinary launches as production-only.' + dependsOn tasks.named('genEclipseRuns') + dependsOn tasks.named('configureEclipseBuildship') + dependsOn prepareEclipseResources + doLast { - File classpathFile = file('.classpath') - if (!classpathFile.isFile()) { - throw new GradleException("Missing generated Eclipse classpath: ${classpathFile}") + obsoleteForgeGradleEclipseLaunches.each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile()) { + String contents = launch.getText('UTF-8') + String obsoleteMainType = + "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"" + if (contents.contains(obsoleteMainType) && !launch.delete()) { + throw new GradleException("Could not remove obsolete ForgeGradle launch ${name}") + } + } } - String mappedPath = forge14MappedRuntime().absolutePath.replace('\\', '/') - String runtimePath = forge14RuntimeJar.archivePath.absolutePath.replace('\\', '/') - String text = classpathFile.getText('UTF-8') - String mappedEntry = "kind=\"lib\" path=\"${mappedPath}\"" - if (!text.contains(mappedEntry)) { - throw new GradleException("Eclipse classpath does not contain mapped Forge 14 runtime: ${mappedPath}") + + // ForgeGradle 7's legacy 1.12 run generator does not know its own + // MC_VERSION token. It emits ${MC_VERSION} as an Eclipse variable, + // which prevents every generated launch from starting. Store the + // target version as a literal environment value instead. + fileTree(project.projectDir) { + include 'run*.launch' + }.files.each { File launch -> + String contents = launch.getText('UTF-8') + contents = contents.replace( + 'key="MC_VERSION" value="${MC_VERSION}"', + "key=\"MC_VERSION\" value=\"${minecraft_version}\"") + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') } - text = text.replace(mappedEntry, "kind=\"lib\" path=\"${runtimePath}\"") - for (String forbidden : ['Mineralogy-1.10.2-3.3.8.26.jar', - 'Mineralogy-1.12.2-3.8.0.53.jar']) { - if (text.contains(forbidden)) { - throw new GradleException("Test-only Mineralogy oracle leaked into Eclipse classpath: ${forbidden}") + + ['runClient.launch', 'runServer.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) { + throw new GradleException("ForgeGradle did not generate ${name}") + } + String contents = launch.getText('UTF-8') + if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + String marker = '' + String attribute = + ' ' + contents = contents.replace(marker, attribute + '\n' + marker) } + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') } - classpathFile.setText(text, 'UTF-8') } } -tasks.matching { it.name == 'genEclipseRuns' }.all { - finalizedBy syncForge14EclipseLaunches -} +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + description = 'Verifies that ordinary generated Eclipse launches exclude tests, fixtures, and probe mods.' + dependsOn tasks.named('isolateEclipseProductionRuns') + dependsOn tasks.named('verifyLegacyOracleIsolation') -tasks.matching { it.name == 'eclipse' }.all { - finalizedBy syncForge14EclipseClasspath + doLast { + File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') + if (!buildshipPreferences.isFile()) { + throw new GradleException('Missing Eclipse Buildship project preferences') + } + File eclipseResources = file('bin/main') + [ + 'mcmod.info', + 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md' + ].each { relative -> + File required = new File(eclipseResources, relative) + if (!required.isFile()) { + throw new GradleException( + "Eclipse production output is missing processed resource ${relative}") + } + } + String eclipseMetadata = new File(eclipseResources, 'mcmod.info').getText('UTF-8') + if (!eclipseMetadata.contains("\"version\": \"${project.version}\"") + || !eclipseMetadata.contains( + "\"mcversion\": \"${minecraft_version}\"")) { + throw new GradleException('Eclipse mcmod.info retains unexpanded build placeholders') + } + List launchFiles = fileTree(project.projectDir) { + include 'runClient.launch' + include 'runServer.launch' + include '.eclipse/runClient.launch' + include '.eclipse/runServer.launch' + }.files as List + if (launchFiles.size() < 2) { + throw new GradleException('ForgeGradle did not generate ordinary client/server Eclipse launches') + } + List allGeneratedLaunches = fileTree(project.projectDir) { + include 'run*.launch' + }.files as List + allGeneratedLaunches.each { launch -> + String contents = launch.getText('UTF-8') + if (contents.contains('${MC_VERSION}')) { + throw new GradleException( + "${launch.name} retains ForgeGradle's unresolved MC_VERSION token") + } + if (!contents.contains( + "key=\"MC_VERSION\" value=\"${minecraft_version}\"")) { + throw new GradleException( + "${launch.name} does not define literal MC_VERSION=${minecraft_version}") + } + } + obsoleteForgeGradleEclipseLaunches.each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile() && launch.getText('UTF-8').contains( + "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"")) { + throw new GradleException( + "Obsolete ForgeGradle ${mainClass} launch remains at ${name}") + } + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'migrationIntegrationTest', 'clientIntegrationTest', + 'os1AbiFixture', 'os3AbiFixture', 'surfaceprobe', 'migrationprobe', 'clientprobe', + 'junit-', 'opentest4j-', 'junit-platform-', + 'Mineralogy-1.10.2-3.3.8.26.jar', 'Mineralogy-1.12.2-3.8.0.53.jar' + ] + launchFiles.each { launch -> + String contents = launch.getText('UTF-8').replace('\\', '/') + List present = forbidden.findAll { contents.contains(it) } + if (!present.isEmpty()) { + throw new GradleException("${launch.name} exposes test code/dependencies: ${present}") + } + if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE') + || !contents.contains('value="true"')) { + throw new GradleException("${launch.name} does not exclude test code") + } + if (!contents.contains('PROJECT_ATTR" value="OreSpawn"')) { + throw new GradleException("${launch.name} targets the wrong Eclipse project") + } + } + } } diff --git a/ci-fixtures/README.md b/ci-fixtures/README.md new file mode 100644 index 00000000..842fdd34 --- /dev/null +++ b/ci-fixtures/README.md @@ -0,0 +1,14 @@ +# Forge 1.12.2 CI fixtures + +These exact published Mineralogy engines and the sealed generated OS3 world +make the Forge 1.12.2 migration and parity checks self-contained on hosted CI. +They are test inputs only and must never enter an OreSpawn release artifact or +an ordinary Eclipse launch. + +- `Mineralogy-1.10.2-3.3.8.26.jar` is the carried 1.10 Cyano-engine oracle. +- `Mineralogy-1.12.2-3.8.0.53.jar` is the native 1.12 Cyano-engine oracle. +- `os3-331-default-source.zip` is the immutable generated-world source used by + both legacy-lineage fresh/reload gates. + +`SHA256SUMS` is authoritative. The Gradle build verifies every hash before +compiling tests or starting a migration runtime. diff --git a/ci-fixtures/SHA256SUMS b/ci-fixtures/SHA256SUMS new file mode 100644 index 00000000..6177e83f --- /dev/null +++ b/ci-fixtures/SHA256SUMS @@ -0,0 +1,3 @@ +88A6237C9A0E2C8891718B68C373E741C78B8494F5E68D8093CA9339F3BC4D87 artifacts/Mineralogy-1.10.2-3.3.8.26.jar +C42E608E5662A94138BD2461019D33283F96E3FB66E28DB91C00A49E9A8005CD artifacts/Mineralogy-1.12.2-3.8.0.53.jar +2852FA549C7A952CCC0EAE1454057CA81BD91F5BB323BEA1030452BB1D82FDFD worlds/os3-331-default-source.zip diff --git a/ci-fixtures/artifacts/Mineralogy-1.10.2-3.3.8.26.jar b/ci-fixtures/artifacts/Mineralogy-1.10.2-3.3.8.26.jar new file mode 100644 index 00000000..13398b48 Binary files /dev/null and b/ci-fixtures/artifacts/Mineralogy-1.10.2-3.3.8.26.jar differ diff --git a/ci-fixtures/artifacts/Mineralogy-1.12.2-3.8.0.53.jar b/ci-fixtures/artifacts/Mineralogy-1.12.2-3.8.0.53.jar new file mode 100644 index 00000000..28d9e785 Binary files /dev/null and b/ci-fixtures/artifacts/Mineralogy-1.12.2-3.8.0.53.jar differ diff --git a/ci-fixtures/worlds/os3-331-default-source.zip b/ci-fixtures/worlds/os3-331-default-source.zip new file mode 100644 index 00000000..db30c20d Binary files /dev/null and b/ci-fixtures/worlds/os3-331-default-source.zip differ diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 2bf01c0c..0529cf81 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -52,7 +52,7 @@ Examples: | Minecraft | Loader | Target | Example full OreSpawn version | | --- | --- | ---: | --- | | 1.10.2 | Forge | `110021` | `4.0.6.110021` | -| 1.12.2 | Forge | `112021` | `4.0.7.112021` | +| 1.12.2 | Forge | `112021` | `4.0.8.112021` | | 1.13.2 | Forge | `113021` | `4.0.6.113021` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | | 1.21.11 | Forge | `121111` | `4.0.6.121111` | diff --git a/gradle.properties b/gradle.properties index cba63c63..88ed9709 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,6 +2,10 @@ # This is required to provide enough memory for the Minecraft decompilation process. org.gradle.jvmargs=-Xmx3G org.gradle.daemon=false +org.gradle.configuration-cache=false +org.gradle.caching=true +org.gradle.parallel=false +net.minecraftforge.gradle.merge-source-sets=false minecraft_version=1.12.2 minecraft_version_range=[1.12.2] @@ -11,10 +15,18 @@ loader_version_range=[14,) mapping_channel=stable mapping_version=39-1.12 +# Release metadata consumed by the generic dispatcher. +loader_name=forge +loader_code=1 +java_version=8 +java_toolchain_version=8.0.502+7 +gradle_java_version=17 +curseforge_project_id=245586 + mod_id=orespawn mod_name=MMD OreSpawn mod_license=LGPL-2.1 -mod_version=4.0.7.112021 -mod_group_id=zone.moddev.mc.orespawn +mod_version=4.0.8.112021 +mod_group=zone.moddev.mc mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7a3265ee..0d4a9516 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 949819d2..2c68b418 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-bin.zip diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..e7080f2e --- /dev/null +++ b/settings.gradle @@ -0,0 +1,5 @@ +plugins { + id('org.gradle.toolchains.foojay-resolver-convention') version '1.0.0' +} + +rootProject.name = 'OreSpawn' diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java new file mode 100644 index 00000000..a6a84da1 --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -0,0 +1,355 @@ +package zone.moddev.mc.orespawn.client; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.client.gui.GuiCreateWorld; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.util.text.TextFormatting; +import net.minecraft.world.GameType; +import net.minecraft.world.WorldSettings; +import net.minecraft.world.WorldType; +import net.minecraftforge.client.event.GuiScreenEvent; +import net.minecraftforge.client.event.RenderWorldLastEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLInitializationEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Build-only client probe. It is compiled and packaged outside every release artifact. */ +@Mod(modid = ClientProbeTestMod.MODID, name = "OreSpawn Client Probe", version = "1", + acceptedMinecraftVersions = "[1.12.2]", dependencies = "required-after:orespawn") +public final class ClientProbeTestMod { + static final String MODID = "clientprobe"; + private static final String WORLD_DIRECTORY = "client-smoke-world"; + private final Set editorRoutes = new HashSet<>(); + private final Set attemptedButtons = new HashSet<>(); + private GuiButton worldSettingsButton; + private int state; + private int stateTicks; + private int firstWorldFrames; + private int reloadWorldFrames; + private int editorFrames; + private boolean worldSettingsOpened; + private boolean longEditorRoundTrip; + + @Mod.EventHandler + public void initialize(FMLInitializationEvent event) { + if (!Boolean.getBoolean("clientprobe.enabled")) return; + MinecraftForge.EVENT_BUS.register(this); + } + + @SubscribeEvent + public void onScreenInitialized(GuiScreenEvent.InitGuiEvent.Post event) { + if (!(event.getGui() instanceof GuiCreateWorld)) return; + for (GuiButton button : event.getButtonList()) { + if (button.id == 0x4F53) worldSettingsButton = button; + } + } + + @SubscribeEvent + public void onScreenDrawn(GuiScreenEvent.DrawScreenEvent.Post event) { + if (event.getGui() instanceof OreSpawnScreen) editorFrames++; + } + + @SubscribeEvent + public void onWorldRendered(RenderWorldLastEvent event) { + if (state == 6) firstWorldFrames++; + if (state == 8) reloadWorldFrames++; + } + + @SubscribeEvent + public void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END || !Boolean.getBoolean("clientprobe.enabled")) return; + Minecraft minecraft = Minecraft.getMinecraft(); + if (++stateTicks > 3600) fail(minecraft, "Timed out in client probe state " + state); + try { + switch (state) { + case 0: + if (minecraft.currentScreen instanceof GuiMainMenu) { + minecraft.displayGuiScreen(new GuiCreateWorld(minecraft.currentScreen)); + nextState(1); + } + break; + case 1: + if (minecraft.currentScreen instanceof GuiCreateWorld && worldSettingsButton != null) { + GuiScreenEvent.ActionPerformedEvent.Pre press = + new GuiScreenEvent.ActionPerformedEvent.Pre(minecraft.currentScreen, + worldSettingsButton, java.util.Collections.singletonList(worldSettingsButton)); + if (!MinecraftForge.EVENT_BUS.post(press) || !press.isCanceled()) { + fail(minecraft, "OreSpawn world-settings action was not canceled"); + } + nextState(2); + } + break; + case 2: + if (minecraft.currentScreen instanceof OreSpawnWorldSettingsScreen && editorFrames >= 2) { + worldSettingsOpened = true; + validateCaptions((OreSpawnWorldSettingsScreen) minecraft.currentScreen); + validateLongEditorRoundTrip(minecraft, minecraft.currentScreen); + nextState(3); + } + break; + case 3: + if (minecraft.currentScreen instanceof OreSpawnWorldSettingsScreen) { + OreSpawnWorldSettingsScreen root = (OreSpawnWorldSettingsScreen) minecraft.currentScreen; + Button target = nextNavigationButton(root); + if (target == null) { + if (editorRoutes.size() < 5) fail(minecraft, + "Only exercised " + editorRoutes.size() + " editor routes: " + editorRoutes); + root.onClose(); + nextState(5); + } else { + GuiScreen before = minecraft.currentScreen; + target.press(); + if (minecraft.currentScreen != before && minecraft.currentScreen instanceof OreSpawnScreen) { + editorRoutes.add(minecraft.currentScreen.getClass().getSimpleName()); + editorFrames = 0; + nextState(4); + } + } + } + break; + case 4: + if (minecraft.currentScreen instanceof OreSpawnScreen && editorFrames >= 2) { + validateCaptions((OreSpawnScreen) minecraft.currentScreen); + ((OreSpawnScreen) minecraft.currentScreen).onClose(); + nextState(3); + } + break; + case 5: + if (minecraft.currentScreen instanceof GuiCreateWorld) { + minecraft.launchIntegratedServer(WORLD_DIRECTORY, "OreSpawn Client Smoke", + new WorldSettings(0L, GameType.CREATIVE, false, false, WorldType.DEFAULT)); + nextState(6); + } + break; + case 6: + if (minecraft.world != null && minecraft.player != null && firstWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(7); + } + break; + case 7: + if (minecraft.world == null && !minecraft.isIntegratedServerRunning() && stateTicks >= 20) { + minecraft.launchIntegratedServer(WORLD_DIRECTORY, "OreSpawn Client Smoke", + new WorldSettings(0L, GameType.CREATIVE, false, false, WorldType.DEFAULT)); + nextState(8); + } + break; + case 8: + if (minecraft.world != null && minecraft.player != null && reloadWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(9); + } + break; + case 9: + if (minecraft.world == null && !minecraft.isIntegratedServerRunning()) { + writeMarker(); + minecraft.shutdown(); + nextState(10); + } + break; + default: + break; + } + } catch (RuntimeException | IOException failure) { + fail(minecraft, failure.toString()); + } + } + + private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) { + for (GuiButton widget : root.buttons) { + if (!(widget instanceof Button) || widget instanceof CycleButton) continue; + Button button = (Button) widget; + String caption = TextFormatting.getTextWithoutFormattingCodes(button.getMessage()); + if (!attemptedButtons.add(caption)) continue; + String lower = caption.toLowerCase(java.util.Locale.ROOT); + if (lower.equals("done") || lower.equals("cancel") || lower.contains("recommended")) continue; + return button; + } + return null; + } + + private static void validateCaptions(OreSpawnScreen screen) { + for (GuiButton widget : screen.buttons) { + String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); + 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: " + widget.displayString); + } + } + } + + private void validateLongEditorRoundTrip(Minecraft minecraft, GuiScreen parent) { + JsonObject root = WorldGeologyProfile.recommended(true).rootCopy(); + JsonObject ores = new JsonObject(); + JsonObject ore = new JsonObject(); + ore.addProperty("enabled", true); + ore.addProperty("block", "minecraft:diamond_ore"); + JsonObject oreDimensions = new JsonObject(); + JsonObject oreRule = new JsonObject(); + oreRule.addProperty("enabled", true); + oreRule.addProperty("min_y", 0); + oreRule.addProperty("max_y", 64); + oreRule.addProperty("frequency", 1.0D); + oreRule.addProperty("quantity", 8); + oreRule.addProperty("discard_chance_on_air_exposure", 0.0D); + oreRule.addProperty("pattern", "vein"); + oreRule.addProperty("height_distribution", "uniform"); + oreRule.addProperty("spread", 8); + oreRule.addProperty("vertical_spread", 4); + oreRule.addProperty("node_size", 4); + oreRule.add("host_families", new JsonArray()); + oreRule.add("host_blocks", values( + "example:ore_host_block_identifier_longer_than_thirty_two_characters")); + oreRule.add("host_tags", values( + "forge:ore_host_tag_identifier_longer_than_thirty_two_characters", + "forge:second_ore_host_tag_in_the_same_comma_separated_list")); + oreDimensions.add("minecraft:overworld", oreRule); + ore.add("dimensions", oreDimensions); + ores.add("example:long_editor_ore", ore); + root.add("ores", ores); + + JsonObject deposits = new JsonObject(); + JsonObject deposit = new JsonObject(); + deposit.addProperty("enabled", true); + deposit.addProperty("block", "minecraft:water"); + JsonObject fluidDimensions = new JsonObject(); + JsonObject fluidRule = new JsonObject(); + fluidRule.addProperty("enabled", true); + fluidRule.addProperty("min_y", 0); + fluidRule.addProperty("max_y", 48); + fluidRule.addProperty("frequency", 0.08D); + fluidRule.addProperty("min_radius", 5); + fluidRule.addProperty("max_radius", 12); + fluidRule.addProperty("min_vertical_radius", 2); + fluidRule.addProperty("max_vertical_radius", 5); + fluidRule.addProperty("max_lobes", 4); + fluidRule.addProperty("min_solid_cover", 2); + fluidRule.addProperty("min_solid_shell", 1); + fluidRule.add("host_families", new JsonArray()); + fluidRule.add("host_blocks", values( + "example:fluid_host_block_identifier_longer_than_thirty_two_characters")); + fluidRule.add("host_tags", values( + "forge:fluid_host_tag_identifier_longer_than_thirty_two_characters", + "forge:second_fluid_host_tag_in_the_same_comma_separated_list")); + fluidRule.add("biome_ids", values( + "example:included_biome_identifier_longer_than_thirty_two_characters")); + fluidRule.add("excluded_biome_ids", values( + "example:excluded_biome_identifier_longer_than_thirty_two_characters")); + fluidRule.add("biome_dictionary", values( + "INCLUDED_DICTIONARY_VALUE_LONGER_THAN_THIRTY_TWO_CHARACTERS", + "SECOND_INCLUDED_DICTIONARY_VALUE_IN_THE_COMMA_LIST")); + fluidRule.add("excluded_biome_dictionary", values( + "EXCLUDED_DICTIONARY_VALUE_LONGER_THAN_THIRTY_TWO_CHARACTERS")); + fluidRule.add("geomes", new JsonObject()); + fluidDimensions.add("minecraft:overworld", fluidRule); + deposit.add("dimensions", fluidDimensions); + deposits.add("example:long_editor_deposit", deposit); + root.add("fluid_deposits", deposits); + // Keep the synthetic profile in the editor's canonical shape so this + // assertion is about preservation of the eight long text fields rather + // than the session adding an unrelated optional empty section. + root.add("geomes", new JsonObject()); + + GeologyEditorSession session = new GeologyEditorSession( + WorldGeologyProfile.recommended(true).withRoot(root)); + String before = session.root().toString(); + + OreDimensionScreen oreScreen = new OreDimensionScreen(parent, session, + "example:long_editor_ore", "minecraft:overworld"); + ((GuiScreen) oreScreen).setWorldAndResolution(minecraft, 640, 480); + pressDone(oreScreen); + + FluidDepositDimensionScreen fluidScreen = new FluidDepositDimensionScreen(parent, session, + "example:long_editor_deposit", "minecraft:overworld"); + ((GuiScreen) fluidScreen).setWorldAndResolution(minecraft, 640, 480); + pressDone(fluidScreen); + + String after = session.root().toString(); + if (!before.equals(after)) { + throw new IllegalStateException("Opening and saving long editor values changed profile JSON\nBefore: " + + before + "\nAfter: " + after); + } + longEditorRoundTrip = true; + } + + private static JsonArray values(String... entries) { + JsonArray result = new JsonArray(); + for (String entry : entries) result.add(new JsonPrimitive(entry)); + return result; + } + + private static void pressDone(OreSpawnScreen screen) { + for (GuiButton widget : screen.buttons) { + if (!(widget instanceof Button)) continue; + String caption = TextFormatting.getTextWithoutFormattingCodes(((Button) widget).getMessage()); + if ("done".equalsIgnoreCase(caption)) { + ((Button) widget).press(); + return; + } + } + throw new IllegalStateException("Editor did not expose its Done action: " + + screen.getClass().getSimpleName()); + } + + private static void stopIntegratedServer(Minecraft minecraft) { + // Match GuiIngameMenu's target-native disconnect path. loadWorld(null) + // coordinates the integrated-server save/stop; installing the replacement + // screen in the same tick prevents EntityRenderer from seeing no world and + // no screen between frames. + if (minecraft.world != null) minecraft.world.sendQuittingDisconnectingPacket(); + minecraft.loadWorld(null); + minecraft.displayGuiScreen(new GuiMainMenu()); + } + + 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("editor_routes", Integer.toString(editorRoutes.size())); + values.setProperty("editor_classes", editorRoutes.toString()); + values.setProperty("first_world_rendered", Boolean.toString(firstWorldFrames >= 8)); + values.setProperty("reload_rendered", Boolean.toString(reloadWorldFrames >= 8)); + values.setProperty("world_directory", WORLD_DIRECTORY); + try (FileOutputStream output = new FileOutputStream(new File("client-smoke-pass.properties"))) { + values.store(output, "OreSpawn Forge 1.12.2 client integration gate"); + } + } + + private void nextState(int next) { + state = next; + stateTicks = 0; + } + + private static void fail(Minecraft minecraft, String message) { + try { + Properties values = new Properties(); values.setProperty("failure", message); + try (FileOutputStream output = new FileOutputStream(new File("client-smoke-failure.properties"))) { + values.store(output, "OreSpawn client probe failure"); + } + } catch (IOException ignored) { + } + minecraft.shutdown(); + throw new IllegalStateException(message); + } +} diff --git a/src/clientIntegrationTest/resources/mcmod.info b/src/clientIntegrationTest/resources/mcmod.info new file mode 100644 index 00000000..519de76b --- /dev/null +++ b/src/clientIntegrationTest/resources/mcmod.info @@ -0,0 +1 @@ +[{"modid":"clientprobe","name":"OreSpawn Client Probe","description":"Build-only OreSpawn client smoke fixture.","version":"1","mcversion":"1.12.2","authorList":["MMD"],"dependencies":["required-after:orespawn@[4.0.6,5.0.0)"]}] diff --git a/src/clientIntegrationTest/resources/pack.mcmeta b/src/clientIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..6e315189 --- /dev/null +++ b/src/clientIntegrationTest/resources/pack.mcmeta @@ -0,0 +1 @@ +{"pack":{"description":"OreSpawn client smoke fixture","pack_format":3}} diff --git a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java index 3c260c9d..d1c39a2c 100644 --- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java +++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java @@ -818,7 +818,7 @@ private static void writeHumanUpgradeReport(Path destination) throws IOException } } List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.7.112021 Upgrade Report"); + lines.add("OreSpawn 4.0.8.112021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Legacy OreSpawn configuration was consumed and translated for OS4."); diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java index 3ec4a7de..fb52bc7d 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java @@ -52,7 +52,7 @@ public class OreSpawn { public static final String MODID = "orespawn"; public static final String NAME = "OreSpawn"; - public static final String VERSION = "4.0.7.112021"; + public static final String VERSION = "4.0.8.112021"; private static final Logger LOGGER = LogManager.getLogger(); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java index d023c6a7..e8eb9a7c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java @@ -155,7 +155,7 @@ private TextFieldWidget placementField(int index, String key, String value) { int fieldWidth = Math.min(72, Math.max(58, columnWidth / 3)); TextFieldWidget box = new TextFieldWidget(font, groupX + columnWidth - fieldWidth, 90 + (row * 24), fieldWidth, 20, new TextComponentString(key)); - box.setValue(value); box.setMaxLength(32); + box.setMaxLength(32); box.setValue(value); placementWidgets.add(OreSpawnScreenLayout.explain(this, addButton(box), placementHelp(key))); return box; @@ -164,7 +164,7 @@ private TextFieldWidget placementField(int index, String key, String value) { private TextFieldWidget hostField(int index, String key, String value) { int x = index == 0 ? left : left + columnWidth + 5; TextFieldWidget box = new TextFieldWidget(font, x, 106, columnWidth, 20, new TextComponentString(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); hostWidgets.add(OreSpawnScreenLayout.explain(this, addButton(box), "tooltip.orespawn." + key)); return box; @@ -174,7 +174,7 @@ private TextFieldWidget biomeField(int index, String key, String value) { int x = (index & 1) == 0 ? left : left + columnWidth + 5; int y = 106 + ((index / 2) * 44); TextFieldWidget box = new TextFieldWidget(font, x, y, columnWidth, 20, new TextComponentString(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); biomeWidgets.add(OreSpawnScreenLayout.explain(this, addButton(box), "tooltip.orespawn.fluid." + key)); return box; diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java index 63bfcd20..11537b00 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java @@ -234,8 +234,8 @@ protected void init() { private TextFieldWidget addPlacementField(int x, int y, String key, String value) { TextFieldWidget box = new TextFieldWidget(font, x, y, columnWidth, 20, new TextComponentString(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(this, box, placementHelp(key)); placementWidgets.add(addButton(box)); return box; @@ -247,8 +247,8 @@ private int compactPlacementFieldY(int row) { private TextFieldWidget addHostField(int x, int y, String key, String value) { TextFieldWidget box = new TextFieldWidget(font, x, y, contentWidth, 20, new TextComponentString(key)); - box.setValue(value); box.setMaxLength(1024); + box.setValue(value); OreSpawnScreenLayout.explain(this, box, "tooltip.orespawn." + key); hostWidgets.add(addButton(box)); return box; @@ -256,8 +256,8 @@ private TextFieldWidget addHostField(int x, int y, String key, String value) { private TextFieldWidget addPatternField(int x, int y, String key, String value) { TextFieldWidget box = new TextFieldWidget(font, x, y, columnWidth, 20, new TextComponentString(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(this, box, "tooltip.orespawn.ore." + key); patternWidgets.add(addButton(box)); return box; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java index 420a97d5..cb303c93 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -152,7 +152,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configDirectory, Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); List missing = missingBlocks(igneous, metamorphic, sedimentary); List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.7.112021 Upgrade Report"); + lines.add("OreSpawn 4.0.8.112021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java index c34590e6..05f6e427 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java @@ -65,7 +65,7 @@ private static void writeInitialUpgradeReport(Path configDirectory, boolean forceRetrogen, boolean flatBedrock, boolean retrogenBedrock, int bedrockLayers) throws IOException { String newline = System.lineSeparator(); - String text = "OreSpawn 4.0.7.112021 Upgrade Report" + newline + String text = "OreSpawn 4.0.8.112021 Upgrade Report" + newline + "================================" + newline + newline + "RESULT: Legacy OreSpawn settings were imported into the OS4 profile." + newline + "- Manage vanilla ores: " + manageVanilla + newline diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg index ad1e0d2d..76641fa4 100644 --- a/src/main/resources/META-INF/accesstransformer.cfg +++ b/src/main/resources/META-INF/accesstransformer.cfg @@ -1,2 +1,2 @@ -public-f net.minecraft.world.WorldProvider field_76578_c # biomeProvider -public-f net.minecraft.world.gen.ChunkGeneratorOverworld field_186001_t # oceanBlock +public-f net.minecraft.world.WorldProvider biomeProvider +public-f net.minecraft.world.gen.ChunkGeneratorOverworld oceanBlock diff --git a/src/test/java/zone/moddev/mc/orespawn/client/ClientButtonTextTest.java b/src/test/java/zone/moddev/mc/orespawn/client/ClientButtonTextTest.java index ded27c15..62ee8531 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/ClientButtonTextTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/ClientButtonTextTest.java @@ -29,6 +29,8 @@ class ClientButtonTextTest { "src", "main", "resources", "assets", "orespawn", "lang", "en_us.lang"); private static final Pattern LITERAL_TRANSLATION = Pattern.compile( "new\\s+TextComponentTranslation\\(\\s*\\\"([^\\\"]+)\\\"\\s*[,)]"); + private static final Pattern VALUE_BEFORE_MAXIMUM = Pattern.compile( + "\\.setValue\\([^;\\r\\n]*\\);\\s*\\w+\\.setMaxLength\\(\\d+\\)"); private static final Set MINECRAFT_1_14_KEYS = new HashSet<>(Arrays.asList( "gui.cancel", "gui.done", "options.off", "options.on")); @@ -64,4 +66,33 @@ void everyLiteralClientTranslationKeyExistsOnTheTarget() throws Exception { assertTrue(missing.isEmpty(), "Client labels must exist in OreSpawn or Minecraft 1.12: " + missing); } + + @Test + void textFieldsApplyTheirMaximumBeforeLoadingExistingValues() throws Exception { + List unsafeInitializers = new ArrayList<>(); + try (Stream files = Files.list(CLIENT_SOURCE)) { + for (Path source : (Iterable) files + .filter(path -> path.getFileName().toString().endsWith(".java"))::iterator) { + String text = new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + if (VALUE_BEFORE_MAXIMUM.matcher(text).find()) { + unsafeInitializers.add(source.getFileName().toString()); + } + } + } + + assertTrue(unsafeInitializers.isEmpty(), + "GuiTextField truncates an existing value before a later maximum is applied: " + + unsafeInitializers); + } + + @Test + void targetTextFieldRetainsLongExistingValuesWhenConfiguredFirst() { + String value = "minecraft:netherrack,minecraft:end_stone,minecraft:stone"; + TextFieldWidget field = new TextFieldWidget(null, 0, 0, 200, 20, "host_blocks"); + + field.setMaxLength(1024); + field.setValue(value); + + assertEquals(value, field.getValue()); + } }