diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc461d3..155a6af6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,153 @@ -name: CI +name: OreSpawn 1.19.4 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.19 + - 'feature/**' + pull_request: + branches: + - master-1.19 + +permissions: + contents: read + +concurrency: + group: orespawn-1.19-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + cold-forge-bootstrap: + name: Cold Forge bootstrap + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 launcher toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 runtime and toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - name: Bootstrap Forge from an empty cache + shell: bash + env: + GRADLE_USER_HOME: ${{ runner.temp }}/orespawn-cold-gradle + run: | + set -euo pipefail + test ! -e .gradle + test ! -e "$GRADLE_USER_HOME" + mkdir -p "$GRADLE_USER_HOME" + chmod +x ./gradlew + ./gradlew verifyMavenizerCompatibilityFixture help \ + --no-daemon --no-build-cache --stacktrace --max-workers=2 \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false \ + | tee "$RUNNER_TEMP/cold-forge-bootstrap.log" + grep -F "OreSpawn Mavenizer compatibility runtime: Java 25.0.3" \ + "$RUNNER_TEMP/cold-forge-bootstrap.log" + grep -F "OreSpawn Mavenizer compatibility: applied 8 rule(s) for net.minecraftforge:forge:1.19.4-45.4.0" \ + "$RUNNER_TEMP/cold-forge-bootstrap.log" + find "$GRADLE_USER_HOME" .gradle/mavenizer \ + -name '*.orespawn-compatibility' -type f -print0 \ + | sort -z | xargs -0 -r sha256sum > "$RUNNER_TEMP/markers-before.sha256" + test -s "$RUNNER_TEMP/markers-before.sha256" + + - name: Verify same-cache preparation is idempotent + shell: bash + env: + GRADLE_USER_HOME: ${{ runner.temp }}/orespawn-cold-gradle + run: | + set -euo pipefail + ./gradlew verifyMavenizerCompatibilityFixture help \ + --no-daemon --no-build-cache --stacktrace --max-workers=2 \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false + find "$GRADLE_USER_HOME" .gradle/mavenizer \ + -name '*.orespawn-compatibility' -type f -print0 \ + | sort -z | xargs -0 -r sha256sum > "$RUNNER_TEMP/markers-after.sha256" + diff -u "$RUNNER_TEMP/markers-before.sha256" "$RUNNER_TEMP/markers-after.sha256" + 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 - with: - java-version: 8 - - run: chmod a+x gradlew - - run: ./gradlew --version --no-daemon - - run: ./gradlew setupCIWorkspace -S - - run: ./gradlew clean build -S + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 launcher toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 runtime and toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - 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 + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + + - name: Upload audited release candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: OreSpawn-1.19.4-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/OreSpawn-4.0.10.119041.jar + build/libs/OreSpawn-4.0.10.119041-sources.jar + build/libs/OreSpawn-4.0.10.119041-javadoc.jar + build/release/SHA256SUMS + CHANGELOG.txt + + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: OreSpawn-1.19.4-diagnostics-${{ github.sha }} + if-no-files-found: ignore + retention-days: 14 + path: | + build/test-results/** + build/reports/** + build/*-run/logs/** + build/surface-integration-run/**/*.properties + build/problems/** diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d5a02752..6326fae0 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,73 +1,76 @@ -# 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" +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' +on: + push: + branches: + - master-1.19 + - 'feature/**' + pull_request: + branches: + - master-1.19 + 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 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' - # 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 + - name: Install pinned Java 8 launcher toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' - # 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 + - name: Install pinned Java 17 runtime and toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - # âœī¸ 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 + - name: Initialize CodeQL + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 + with: + languages: java-kotlin - #- run: | - # make bootstrap - # make release + - name: Compile production code + run: | + chmod +x ./gradlew + gradle_args=( + clean classes --no-daemon --stacktrace + "-Dorg.gradle.java.installations.paths=$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + for attempt in 1 2 3; do + if ./gradlew "${gradle_args[@]}"; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "CodeQL compilation failed after $attempt attempts." >&2 + exit 1 + fi + echo "::warning::CodeQL compilation attempt $attempt failed; retrying with the preserved Forge cache." + done - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - 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..568f47a8 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.19 + - 'feature/**' + pull_request: + branches: + - master-1.19 + +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/.gitignore b/.gitignore index e84eddae..dc855b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ run classes logs /mcmodsrepo/ +/src/generated/resources/META-INF/orespawn/docs/ +/config/orespawn-worldgen.json # machine-specific agent context (public integration notes live under /docs) /AGENTS.md @@ -45,6 +47,8 @@ logs # local regression, benchmark, and profiling evidence /run-*/ /benchmark-*/ +/preferred-seed-*/ +/validation/ /regression-*/ /evidence/ /evidence-*/ diff --git a/CHANGELOG.txt b/CHANGELOG.txt index c146145f..c3067502 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,32 @@ +Version 4.0.10.119041 + +* Evaluate Stable Layers rock min_y and max_y bounds against actual world Y + instead of the vertically shifted formation coordinate. +* Preserve the shifted formation identity for layer, family, and rock choice + while preventing vanilla Stone fallback near dimension floors and ceilings. +* Apply the correction only while generating new chunks; existing chunks and + saved profiles remain unchanged. + +Version 4.0.9.119041 + +* Replace provider-declared natural terrain hosts during the existing geology + scan before structure and vegetation features can author matching blocks. +* Keep air, fluids, bedrock, and block entities protected even when their block + IDs are mistakenly declared as terrain hosts. +* Apply the correction only while generating new chunks; existing chunks and + saved profiles remain unchanged. + +Version 4.0.8.119041 + +* 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, audited release artifacts, SHA-256 + checksums, Buildship launches, and guarded release automation. +* Export the complete bundled guide, including the shared version policy, to + the player-facing configuration folder. +* Forge 1.12.2's 4.0.7 packaged access-transformer repair is target-specific + and is not applicable to Forge 1.19.4. + Version 4.0.6.119041 * Adopt target-qualified four-component versions so Minecraft and loader compatibility can be identified from the mod version. 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 43fff513..99400779 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.19)](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml?query=branch%3Amaster-1.19) + # MMD OreSpawn OreSpawn 4 is a provider-driven world-generation engine for Minecraft 1.19.4. @@ -12,6 +17,10 @@ End" policy used by mods such as Base Metals. This is not the unrelated mod that adds mobs and dimensions under the same name. +This branch builds target-qualified version `4.0.10.119041`: the OreSpawn 4.0.10 +feature set for Minecraft 1.19.4 and Forge. See the +[versioning policy](docs/VERSIONS.md) for the encoding and release convention. + ## What Happens When It Is Installed? OreSpawn is deliberately passive on its own. It does not replace stone, remove @@ -86,11 +95,13 @@ exported to `config/orespawn-guide/` without overwriting existing files. ## Building -Use Java 17 from the repository root: +Run Gradle with Java 17 from the repository root. Install the exact Temurin +`17.0.1+12` toolchain used to compile production code and test fixtures for +Minecraft 1.19.4; the build rejects a different Java 17 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, @@ -100,8 +111,13 @@ dimensions. It also proves later vegetation, structures, and block entities survive, 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 6 project in Eclipse. +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 45.4.0, official Minecraft +1.19.4 mappings, resource pack format 13, and data pack format 12. Ordinary Eclipse launches exclude tests +and fixtures. Published jars are deterministic, SRG-reobfuscated for the Forge +45 runtime, audited for their four access-transformer rules 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 5a5256bf..8b16bfab 100644 --- a/build.gradle +++ b/build.gradle @@ -1,354 +1,468 @@ +import groovy.json.JsonSlurper +import groovy.xml.XmlSlurper +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.gradle' version '[6.0,6.2)' + id 'net.minecraftforge.renamer' version '1.1.5' + id 'net.minecraftforge.accesstransformers' version '2.0.0' + id 'net.minecraftforge.gradle' version '7.0.34' } -version = mod_version -group = mod_group_id +def sha256Of = { File inputFile -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + inputFile.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() +} -base { - archivesName = "OreSpawn-${minecraft_version}" +def mavenizerToolsDirectory = layout.projectDirectory.dir('ci-fixtures/tools') +def mavenizerCompatibilityJar = mavenizerToolsDirectory.file( + 'minecraft-mavenizer-0.5.21-orespawn-compat.jar') +def mavenizerRuleManifest = mavenizerToolsDirectory.file( + 'minecraft-source-compatibility.json') +def mavenizerSourcePatch = mavenizerToolsDirectory.file( + 'minecraft-mavenizer-0.5.21-orespawn-compat.patch') +def mavenizerLicense = mavenizerToolsDirectory.file('LICENSE-MAVENIZER.txt') +def mavenizerReadme = mavenizerToolsDirectory.file('README.md') +def mavenizerFixtureChecksums = [ + (mavenizerCompatibilityJar.asFile): + '341B3FB10ABA6EA7DC612FBE519DC290A1C81867AED5DD320ACD783688AC49DE', + (mavenizerRuleManifest.asFile): + '33A3217261EBB8F37D45F50BD2F8CE70B221C014A5C15F4C5F8F9091AD6D1EC8', + (mavenizerSourcePatch.asFile): + 'D856190DEF5574716C5E4F4379912C451B1F5952B0B111576651A98EECF58544', + (mavenizerLicense.asFile): + '20C17D8B8C48A600800DFD14F95D5CB9FF47066A9641DDEAB48DC54AEC96E331', + (mavenizerReadme.asFile): + '88806D79B0FD4479FA07CCFF30A44A6D90F1BF822665EFBFCCCBD5C99F65FBB3' +] + +// ForgeGradle invokes Mavenizer while the project is being configured. Verify +// the executable and its target-rule manifest before any Mavenizer code runs. +[mavenizerCompatibilityJar.asFile, mavenizerRuleManifest.asFile].each { fixture -> + if (!fixture.isFile()) { + throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}") + } + String actual = sha256Of(fixture) + String expected = mavenizerFixtureChecksums[fixture] + if (actual != expected) { + throw new GradleException("Mavenizer compatibility fixture checksum mismatch for " + + "${fixture.name}: expected ${expected}, found ${actual}") + } +} + +fgtools.configure('mavenizer') { + classpath.from(mavenizerCompatibilityJar) + mainClass.set('net.minecraftforge.mcmaven.cli.Main') + javaLauncher.set(javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.ADOPTIUM + }) } -// Mojang ships Java 17 to end users in 1.18+, so your mod should target Java 17. +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('.') +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 +def expectedMavenGroup = 'zone.moddev.mc.orespawn' +def expectedMavenArtifact = 'OreSpawn' +def expectedMavenCoordinate = "${expectedMavenGroup}:${expectedMavenArtifact}:${project.version}" + java { - toolchain.languageVersion = JavaLanguageVersion.of(17) + toolchain { + languageVersion = JavaLanguageVersion.of(17) + vendor = JvmVendorSpec.ADOPTIUM + } withSourcesJar() withJavadocJar() } -println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}" +tasks.withType(JavaCompile).configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(17) + vendor = JvmVendorSpec.ADOPTIUM + } + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' + options.compilerArgs.addAll(['-Xmaxerrs', '1000']) +} +tasks.named('compileTestJava', JavaCompile) { options.compilerArgs.add('-proc:none') } +tasks.withType(Test).configureEach { + useJUnitPlatform() + workingDir = project.projectDir +} +tasks.withType(Javadoc).configureEach { + failOnError = false + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('notimestamp', true) +} +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +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 { - // The mappings can be changed at any time and must be in the following format. - // Channel: Version: - // official MCVersion Official field/method names from Mojang mapping files - // parchment YYYY.MM.DD-MCVersion Open community-sourced parameter names and javadocs layered on top of official - // - // You must be aware of the Mojang license when using the 'official' or 'parchment' mappings. - // See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md - // - // Parchment is an unofficial project maintained by ParchmentMC, separate from MinecraftForge - // Additional setup is needed to use their mappings: https://github.com/ParchmentMC/Parchment/wiki/Getting-Started - // - // Use non-default mappings at your own risk. They may not always work. - // Simply re-run your setup task after changing the mappings to update your workspace. - mappings channel: mapping_channel, version: mapping_version - - // When true, this property will have all Eclipse/IntelliJ IDEA run configurations run the "prepareX" task for the given run configuration before launching the game. - // In most cases, it is not necessary to enable. - // enableEclipsePrepareRuns = true - // enableIdeaPrepareRuns = true - - // This property allows configuring Gradle's ProcessResources task(s) to run on IDE output locations before launching the game. - // It is REQUIRED to be set to true for this template to function. - // See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html - copyIdeResources = true - - // When true, this property will add the folder name of all declared run configurations to generated IDE run configurations. - // The folder name can be set on a run configuration using the "folderName" property. - // By default, the folder name of a run configuration is the name of the Gradle project containing it. - // generateRunFolders = true - - // This property enables access transformers for use in development. - // They will be applied to the Minecraft artifact. - // The access transformer file can be anywhere in the project. - // However, it must be at "META-INF/accesstransformer.cfg" in the final mod jar to be loaded by Forge. - // This default location is a best practice to automatically put the file in the right place in the final jar. - // See https://docs.minecraftforge.net/en/latest/advanced/accesstransformers/ for more information. - accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg') - - // Default run configurations. - // These can be tweaked, removed, or duplicated as needed. + mappings channel: project.mapping_channel, version: project.mapping_version + accessTransformer = 'META-INF/accesstransformer.cfg' runs { - // applies to all the run configs below configureEach { - workingDirectory project.file('run') - - // Recommended logging data for a userdev environment - // The markers can be added/remove as needed separated by commas. - // "SCAN": For mods scan. - // "REGISTRIES": For firing of registry events. - // "REGISTRYDUMP": For getting the contents of all registries. - property 'forge.logging.markers', 'REGISTRIES' - - // Recommended logging level for the console - // You can set various levels here. - // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels - property 'forge.logging.console.level', 'debug' - - // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. - property 'forge.enabledGameTestNamespaces', mod_id - - mods { - orespawn { - source sourceSets.main - } - } + workingDir.convention layout.projectDirectory.dir('run') + jvmArgs '--add-opens', 'java.base/java.lang.invoke=ALL-UNNAMED' + systemProperty 'forge.logging.markers', 'REGISTRIES' + systemProperty 'forge.logging.console.level', 'debug' + mods { orespawn { source sourceSets.main } } } - - client { - // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. - property 'forge.enabledGameTestNamespaces', mod_id - } - - server { - property 'forge.enabledGameTestNamespaces', mod_id - args '--nogui' - if (providers.gradleProperty('orespawnBenchmarkMode').isPresent()) { - property 'orespawn.worldgenBenchmarkMode', providers.gradleProperty('orespawnBenchmarkMode').get() - property 'orespawn.worldgenBenchmarkRadius', - providers.gradleProperty('orespawnBenchmarkRadius').getOrElse('8') - property 'orespawn.worldgenBenchmarkRepetitions', - providers.gradleProperty('orespawnBenchmarkRepetitions').getOrElse('5') - property 'orespawn.worldgenBenchmarkVanillaOres', - providers.gradleProperty('orespawnBenchmarkVanillaOres').getOrElse('false') - property 'orespawn.worldgenBenchmarkOreAudit', - providers.gradleProperty('orespawnBenchmarkOreAudit').getOrElse('false') - property 'orespawn.worldgenBenchmarkFluidAudit', - providers.gradleProperty('orespawnBenchmarkFluidAudit').getOrElse('false') - if (providers.gradleProperty('orespawnBenchmarkBiomeAudit').isPresent()) { - property 'orespawn.worldgenBenchmarkBiomeAudit', - providers.gradleProperty('orespawnBenchmarkBiomeAudit').get() - } - if (providers.gradleProperty('orespawnBenchmarkBlockAudit').isPresent()) { - property 'orespawn.worldgenBenchmarkBlockAudit', - providers.gradleProperty('orespawnBenchmarkBlockAudit').get() - } - property 'orespawn.worldgenBenchmarkCenterX', - providers.gradleProperty('orespawnBenchmarkCenterX').getOrElse('1024') - property 'orespawn.worldgenBenchmarkCenterZ', - providers.gradleProperty('orespawnBenchmarkCenterZ').getOrElse('1024') - property 'orespawn.worldgenBenchmarkCenterStep', - providers.gradleProperty('orespawnBenchmarkCenterStep').getOrElse('64') - property 'orespawn.worldgenBenchmarkDimension', - providers.gradleProperty('orespawnBenchmarkDimension').getOrElse('overworld') - if (providers.gradleProperty('orespawnBenchmarkBiomeType').isPresent()) { - property 'orespawn.worldgenBenchmarkBiomeType', - providers.gradleProperty('orespawnBenchmarkBiomeType').get() - } - property 'orespawn.worldgenBenchmarkStopServer', 'true' - if (providers.gradleProperty('worldgenJfrFile').isPresent()) { - jvmArg "-XX:StartFlightRecording=filename=${providers.gradleProperty('worldgenJfrFile').get()},settings=profile,dumponexit=true" - } - } + register('client') + register('server') { args '--nogui' } + register('data') { + workingDir.convention layout.projectDirectory.dir('run-data') + args '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/'), + '--existing', file('src/main/resources/') } - - // This run config launches GameTestServer and runs all registered gametests, then exits. - // By default, the server will crash when no gametests are provided. - // The gametest system is also enabled by default for other run configs under the /test command. - gameTestServer { - property 'forge.enabledGameTestNamespaces', mod_id - if (providers.gradleProperty('orespawnBenchmarkMode').isPresent()) { - property 'orespawn.worldgenBenchmarkMode', providers.gradleProperty('orespawnBenchmarkMode').get() - property 'orespawn.worldgenBenchmarkRadius', - providers.gradleProperty('orespawnBenchmarkRadius').getOrElse('4') - property 'orespawn.worldgenBenchmarkRepetitions', - providers.gradleProperty('orespawnBenchmarkRepetitions').getOrElse('3') - property 'orespawn.worldgenBenchmarkVanillaOres', - providers.gradleProperty('orespawnBenchmarkVanillaOres').getOrElse('false') - property 'orespawn.worldgenBenchmarkOreAudit', - providers.gradleProperty('orespawnBenchmarkOreAudit').getOrElse('false') - property 'orespawn.worldgenBenchmarkFluidAudit', - providers.gradleProperty('orespawnBenchmarkFluidAudit').getOrElse('false') - if (providers.gradleProperty('orespawnBenchmarkBiomeAudit').isPresent()) { - property 'orespawn.worldgenBenchmarkBiomeAudit', - providers.gradleProperty('orespawnBenchmarkBiomeAudit').get() - } - if (providers.gradleProperty('orespawnBenchmarkBlockAudit').isPresent()) { - property 'orespawn.worldgenBenchmarkBlockAudit', - providers.gradleProperty('orespawnBenchmarkBlockAudit').get() - } - property 'orespawn.worldgenBenchmarkCenterX', - providers.gradleProperty('orespawnBenchmarkCenterX').getOrElse('256') - property 'orespawn.worldgenBenchmarkCenterZ', - providers.gradleProperty('orespawnBenchmarkCenterZ').getOrElse('256') - property 'orespawn.worldgenBenchmarkCenterStep', - providers.gradleProperty('orespawnBenchmarkCenterStep').getOrElse('64') - property 'orespawn.worldgenBenchmarkDimension', - providers.gradleProperty('orespawnBenchmarkDimension').getOrElse('overworld') - if (providers.gradleProperty('orespawnBenchmarkBiomeType').isPresent()) { - property 'orespawn.worldgenBenchmarkBiomeType', - providers.gradleProperty('orespawnBenchmarkBiomeType').get() - } - property 'orespawn.worldgenBenchmarkStopServer', 'true' - if (providers.gradleProperty('worldgenJfrFile').isPresent()) { - jvmArg "-XX:StartFlightRecording=filename=${providers.gradleProperty('worldgenJfrFile').get()},settings=profile,dumponexit=true" - } - } + register('surfaceIntegrationFresh') { + mainClass = 'cpw.mods.bootstraplauncher.BootstrapLauncher' + workingDir.convention layout.buildDirectory.dir('surface-integration-run') + jvmArgs '-p', '{modules}', '--add-modules', 'ALL-MODULE-PATH', + '--add-opens', 'java.base/java.lang.invoke=cpw.mods.securejarhandler', + '--add-opens', 'java.base/java.util.jar=cpw.mods.securejarhandler', + '--add-exports', 'java.base/sun.security.util=cpw.mods.securejarhandler', + '--add-exports', 'jdk.naming.dns/com.sun.jndi.dns=java.naming' + args '--launchTarget', 'forgeserveruserdev', '--gameDir', '.', + '--fml.forgeVersion', forge_version, '--fml.mcVersion', minecraft_version, + '--fml.forgeGroup', 'net.minecraftforge', '--fml.mcpVersion', mcp_version, + '--nogui' + systemProperty 'legacyClassPath.file', '{minecraft_classpath_file}' + systemProperty 'ignoreList', + 'bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-' + systemProperty 'mergeModules', + 'jna-5.12.1.jar,jna-platform-5.12.1.jar,java-objc-bridge-1.0.0.jar' + systemProperty 'surfaceprobe.integrationPhase', 'fresh' + environment 'MCP_MAPPINGS', '{mcp_mappings}' } - - data { - // example of overriding the workingDirectory set in configureEach above - workingDirectory project.file('run-data') - - // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. - args '--mod', mod_id, '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/') + register('surfaceIntegrationReload') { + mainClass = 'cpw.mods.bootstraplauncher.BootstrapLauncher' + workingDir.convention layout.buildDirectory.dir('surface-integration-run') + jvmArgs '-p', '{modules}', '--add-modules', 'ALL-MODULE-PATH', + '--add-opens', 'java.base/java.lang.invoke=cpw.mods.securejarhandler', + '--add-opens', 'java.base/java.util.jar=cpw.mods.securejarhandler', + '--add-exports', 'java.base/sun.security.util=cpw.mods.securejarhandler', + '--add-exports', 'jdk.naming.dns/com.sun.jndi.dns=java.naming' + args '--launchTarget', 'forgeserveruserdev', '--gameDir', '.', + '--fml.forgeVersion', forge_version, '--fml.mcVersion', minecraft_version, + '--fml.forgeGroup', 'net.minecraftforge', '--fml.mcpVersion', mcp_version, + '--nogui' + systemProperty 'legacyClassPath.file', '{minecraft_classpath_file}' + systemProperty 'ignoreList', + 'bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-' + systemProperty 'mergeModules', + 'jna-5.12.1.jar,jna-platform-5.12.1.jar,java-objc-bridge-1.0.0.jar' + systemProperty 'surfaceprobe.integrationPhase', 'reload' + environment 'MCP_MAPPINGS', '{mcp_mappings}' } - - ['Fresh', 'Reload'].each { String phase -> - create("surfaceIntegration${phase}") { - parent runs.server - workingDirectory layout.buildDirectory.dir('surface-integration-run').get().asFile - property 'forge.logging.console.level', 'info' - property 'surfaceprobe.integrationPhase', phase.toLowerCase(Locale.ROOT) - args '--nogui' - } - } } } -// Include resources generated by data generators. -sourceSets.main.resources { srcDir 'src/generated/resources' } - -repositories { - // Put repositories for dependencies here - // ForgeGradle automatically adds the Forge maven and Maven Central for you +def bundledDocumentationDirectory = layout.projectDirectory.dir( + 'src/generated/resources/META-INF/orespawn/docs') +def prepareBundledDocumentation = tasks.register('prepareBundledDocumentation', Sync) { + group = 'build' + description = 'Stages public documentation as a generated production resource tree.' + from('docs') + into(bundledDocumentationDirectory) +} - // If you have mod jar dependencies in ./libs, you can declare them as a repository like so: - // flatDir { - // dir 'libs' - // } +sourceSets.main.resources { + // Eclipse rebuilds bin/main from declared resource source folders. Keeping + // the generated documentation in the source set prevents a Buildship + // refresh from silently removing the guide copied by processResources. + srcDir 'src/generated/resources' } -dependencies { - // Specify the version of Minecraft to use. - // Any artifact can be supplied so long as it has a "userdev" classifier artifact and is a compatible patcher artifact. - // The "userdev" classifier will be requested and setup by ForgeGradle. - // If the group id is "net.minecraft" and the artifact id is one of ["client", "server", "joined"], - // then special handling is done to allow a setup of a vanilla dependency without the use of an external repository. - minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" - - testImplementation platform('org.junit:junit-bom:5.10.2') - testImplementation 'org.junit.jupiter:junit-jupiter' - testImplementation 'org.junit.platform:junit-platform-launcher' - - // Real mod deobf dependency examples - these get remapped to your current mappings - // compileOnly fg.deobf("mezz.jei:jei-${mc_version}:${jei_version}:api") // Adds JEI API as a compile dependency - // runtimeOnly fg.deobf("mezz.jei:jei-${mc_version}:${jei_version}") // Adds the full JEI mod as a runtime dependency - // implementation fg.deobf("com.tterrag.registrate:Registrate:MC${mc_version}-${registrate_version}") // Adds registrate as a dependency - - // Example mod dependency using a mod jar from ./libs with a flat dir repository - // This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar - // The group id is ignored when searching -- in this case, it is "blank" - // implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}") - - // For more info: - // http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html - // http://www.gradle.org/docs/current/userguide/dependency_management.html -} - -// Example for how to get properties into the manifest for reading at runtime. -tasks.named('jar', Jar).configure { - manifest { - attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', - 'Specification-Version' : '1', // We are version 1 of ourselves - 'Implementation-Title' : project.name, - 'Implementation-Version' : project.jar.archiveVersion, - 'Implementation-Vendor' : 'SkyBlade1978', - 'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), - 'OreSpawn-API-Version' : '1' - ]) - } +def forgeRunModClassesDirectory = file("${buildDir}/forge-run-mod-classes/main") +def prepareForgeRunModClasses = tasks.register('prepareForgeRunModClasses', Sync) { + dependsOn tasks.named('classes') + from sourceSets.main.output.classesDirs + from sourceSets.main.output.resourcesDir + into forgeRunModClassesDirectory +} - // This is the preferred method to reobfuscate your jar file - finalizedBy 'reobfJar' +def configureForge45Run = { JavaExec runTask, String launchTarget -> + runTask.dependsOn prepareForgeRunModClasses + // Forge 45 resolves the complete exploded mod from one merged output. + runTask.environment 'MOD_CLASSES', "${mod_id}%%${forgeRunModClassesDirectory}" +} +tasks.withType(JavaExec).configureEach { JavaExec runTask -> + Map targets = [ + runClient: 'fmluserdevclient', + runServer: 'fmluserdevserver', + runData: 'fmluserdevdata', + runSurfaceIntegrationFresh: 'fmluserdevserver', + runSurfaceIntegrationReload: 'fmluserdevserver' + ] + String launchTarget = targets.get(runTask.name) + if (launchTarget != null) configureForge45Run(runTask, launchTarget) } -tasks.named('processResources', ProcessResources).configure { - from('docs/AGENTS.md') { - into '' - rename { 'AGENTS.md' } - } - from('docs') { - into 'META-INF/orespawn/docs' +repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + exclusiveContent { + forRepository { maven { url = 'https://repo.spongepowered.org/repository/maven-public' } } + filter { includeGroupAndSubgroups('org.spongepowered') } } + mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } } -// However if you are in a multi-project build, dev time needs unobfed jar files, so you can delay the obfuscation until publishing by doing: -// tasks.named('publish').configure { -// dependsOn 'reobfJar' -// } +def fixtureRoot = file("${rootDir}/ci-fixtures") +def mineralogy5OracleJar = new File(fixtureRoot, + 'artifacts/Mineralogy-1.18.2-5.4.0.jar') +def mineralogy5OracleSha256 = + 'CCA84E9270585478B08F54BA091AD56AB2CB390386C650ED78B2673DC57403EB' -publishing { - publications { - register('mavenJava', MavenPublication) { - artifact jar - artifact sourcesJar - artifact javadocJar +tasks.register('verifyLegacyFixtures') { + group = 'verification' + description = 'Verifies the sealed Mineralogy 1.18.2 5.4.0 oracle used only by isolated tests.' + inputs.file mineralogy5OracleJar + doLast { + if (!mineralogy5OracleJar.isFile()) { + throw new GradleException("Missing mandatory Mineralogy oracle: ${mineralogy5OracleJar}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + mineralogy5OracleJar.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 actual = digest.digest().encodeHex().toString().toUpperCase() + if (actual != mineralogy5OracleSha256) { + throw new GradleException("Mineralogy oracle checksum mismatch: ${actual}") } } - repositories { - maven { - url "file://${project.projectDir}/mcmodsrepo" +} + +tasks.register('verifyMavenizerCompatibilityFixture') { + group = 'verification' + description = 'Verifies the sealed target-aware ForgeGradle Mavenizer compatibility tool.' + inputs.files mavenizerFixtureChecksums.keySet() + doLast { + mavenizerFixtureChecksums.each { fixture, expected -> + if (!fixture.isFile()) { + throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}") + } + String actual = sha256Of(fixture) + if (actual != expected) { + throw new GradleException("Mavenizer compatibility fixture checksum mismatch for " + + "${fixture.name}: expected ${expected}, found ${actual}") + } + } + + def parsed = new JsonSlurper().parse(mavenizerRuleManifest.asFile) + if (parsed.schema != 1 || parsed.targets.keySet() != + ['net.minecraftforge:forge:1.19.4-45.4.0'] as Set) { + throw new GradleException('Mavenizer target-rule manifest has unexpected targets or schema') + } + def forgeRules = parsed.targets['net.minecraftforge:forge:1.19.4-45.4.0'] + def ruleIds = forgeRules.rules.collect { it.id } + if (forgeRules.expectedApplications != 8 || forgeRules.rules.size() != 8 + || ruleIds.toSet().size() != 8) { + throw new GradleException('Mavenizer target-rule manifest must define eight unique Forge 45 rules') + } + + ZipFile fixtureJar = new ZipFile(mavenizerCompatibilityJar.asFile) + try { + def embeddedEntry = fixtureJar.getEntry( + 'META-INF/orespawn/minecraft-source-compatibility.json') + if (embeddedEntry == null) { + throw new GradleException('Mavenizer fixture is missing its embedded target-rule manifest') + } + byte[] embedded = fixtureJar.getInputStream(embeddedEntry).withCloseable { it.readAllBytes() } + byte[] external = mavenizerRuleManifest.asFile.bytes + if (!Arrays.equals(embedded, external)) { + throw new GradleException('Embedded and externally audited Mavenizer manifests differ') + } + + def patcherEntry = fixtureJar.getEntry( + 'net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.class') + if (patcherEntry == null) { + throw new GradleException('Mavenizer fixture is missing SourceCompatibilityPatcher') + } + byte[] classHeader = fixtureJar.getInputStream(patcherEntry).withCloseable { + it.readNBytes(8) + } + if (classHeader.length != 8 || classHeader[0..3] != + [0xCA, 0xFE, 0xBA, 0xBE].collect { (byte) it }) { + throw new GradleException('Mavenizer compatibility class has an invalid class header') + } + int classMajor = ((classHeader[6] & 0xff) << 8) | (classHeader[7] & 0xff) + if (classMajor != 69) { + throw new GradleException("Mavenizer compatibility tool must use Java 25 bytecode, found ${classMajor}") + } + + def jarManifestEntry = fixtureJar.getEntry('META-INF/MANIFEST.MF') + Manifest jarManifest = new Manifest(fixtureJar.getInputStream(jarManifestEntry)) + if (jarManifest.mainAttributes.getValue('Main-Class') != + 'net.minecraftforge.mcmaven.cli.Main') { + throw new GradleException('Mavenizer compatibility fixture has an unexpected main class') + } + } finally { + fixtureJar.close() + } + + String readme = mavenizerReadme.asFile.getText('UTF-8') + String patchText = mavenizerSourcePatch.asFile.getText('UTF-8') + String licenseText = mavenizerLicense.asFile.getText('UTF-8') + if (!readme.contains('6968241ce7a0a902cdc1c534b976e8373a423091') + || !readme.contains('Temurin `25.0.3+9`')) { + throw new GradleException('Mavenizer provenance or Java 25 build instructions are incomplete') + } + if (!patchText.contains('SourceCompatibilityPatcher.java') + || !patchText.contains('minecraft-source-compatibility.json')) { + throw new GradleException('Mavenizer source patch is incomplete') + } + if (!licenseText.contains('GNU LESSER GENERAL PUBLIC LICENSE')) { + throw new GradleException('Mavenizer LGPL-2.1 licence fixture is incomplete') } } } -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation +dependencies { + implementation minecraft.dependency( + "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") + 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' + testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2' } -tasks.named('javadoc', Javadoc).configure { - options.encoding = 'UTF-8' - options.addStringOption('Xdoclint:none', '-quiet') +tasks.named('compileTestJava', JavaCompile) { dependsOn tasks.named('verifyLegacyFixtures') } +tasks.named('test', Test) { + dependsOn tasks.named('verifyLegacyFixtures') + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5OracleJar.absolutePath } -tasks.named('test', Test).configure { - useJUnitPlatform() - // Unit tests inspect target files relative to the checkout, but they do - // not need Forge's rolling runtime files. A console-only test logger keeps - // them from contending with Eclipse/client logs in this working directory. - systemProperty 'log4j.configurationFile', file('src/test/resources/log4j2-test.xml').absolutePath - // Loaded only through an isolated URLClassLoader by the parity test. This - // is deliberately not a Gradle dependency and cannot leak into Eclipse or - // a published OreSpawn jar. - File mineralogy5Oracle = file('../../MinecraftMineralogy 118/MinecraftMineralogy/build/libs/Mineralogy-1.18.2-5.4.0.jar') - if (mineralogy5Oracle.isFile()) { - systemProperty 'orespawn.mineralogy5Oracle', mineralogy5Oracle.absolutePath - } -} - -// Several registry-focused tests initialize the real global config singleton. -// Keep that target-native coverage without creating or changing a developer's -// checkout config as a side effect of `test` or `build`. -def unitTestWorldgenConfig = file('config/orespawn-worldgen.json') -def unitTestWorldgenConfigWasPresent = false -byte[] unitTestWorldgenConfigBytes = null -tasks.named('test', Test).configure { - doFirst { - unitTestWorldgenConfigWasPresent = unitTestWorldgenConfig.isFile() - unitTestWorldgenConfigBytes = unitTestWorldgenConfigWasPresent - ? unitTestWorldgenConfig.bytes : null +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' + dependsOn tasks.named('verifyLegacyFixtures') + doLast { + configurations.findAll { it.canBeResolved }.each { configuration -> + if (configuration.files.any { it.canonicalFile == mineralogy5OracleJar.canonicalFile }) { + throw new GradleException("Mineralogy oracle leaked into ${configuration.name}") + } + } } } -def preserveDeveloperWorldgenConfig = tasks.register('preserveDeveloperWorldgenConfig') { + +def java17Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(17) + vendor = JvmVendorSpec.ADOPTIUM +} +tasks.register('verifyJava17Toolchain') { + group = 'verification' doLast { - if (unitTestWorldgenConfigWasPresent) { - byte[] after = unitTestWorldgenConfig.isFile() ? unitTestWorldgenConfig.bytes : null - if (after == null || !java.util.Arrays.equals(unitTestWorldgenConfigBytes, after)) { - unitTestWorldgenConfig.parentFile.mkdirs() - unitTestWorldgenConfig.bytes = unitTestWorldgenConfigBytes - throw new GradleException('Unit tests changed config/orespawn-worldgen.json; the original was restored') - } - } else if (unitTestWorldgenConfig.isFile()) { - delete unitTestWorldgenConfig + def metadata = java17Launcher.get().metadata + if (project.java_toolchain_version != '17.0.1+12' + || metadata.vendor.toString() != 'Eclipse Temurin' + || metadata.javaRuntimeVersion != '17.0.1+12') { + throw new GradleException("Expected Temurin ${project.java_toolchain_version}, found " + + "${metadata.vendor} ${metadata.javaRuntimeVersion} at ${metadata.installationPath}") } } } -tasks.named('test') { - finalizedBy preserveDeveloperWorldgenConfig +tasks.named('check') { + dependsOn tasks.named('verifyLegacyOracleIsolation') + dependsOn tasks.named('verifyJava17Toolchain') + dependsOn tasks.named('verifyMavenizerCompatibilityFixture') +} + +tasks.named('processResources', ProcessResources) { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + inputs.property('version', project.version) + inputs.property('minecraft_version', project.minecraft_version) + inputs.property('forge_version_range', project.forge_version_range) + inputs.property('loader_version_range', project.loader_version_range) + inputs.property('minecraft_version_range', project.minecraft_version_range) + filesMatching('META-INF/mods.toml') { + expand([ + version: project.version, + minecraft_version: project.minecraft_version, + forge_version_range: project.forge_version_range, + loader_version_range: project.loader_version_range, + minecraft_version_range: project.minecraft_version_range + ]) + } + from('docs/AGENTS.md') { + into '' + rename { 'AGENTS.md' } + } + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) +} + +def prepareEclipseResources = tasks.register('prepareEclipseResources') { + group = 'ide' + dependsOn tasks.named('processResources') + doLast { + project.copy { + from(layout.buildDirectory.dir('resources/main')) + into(layout.projectDirectory.dir('bin/main')) + } + } } // A Forge process is not green merely because it returns exit code zero. The // loader can log a worldgen/linkage failure and still shut down normally. -def acceptedForge40LogNoise = [ +def acceptedForge45LogNoise = [ ~/FML appears to be missing any signature data/, ~/Found multiple arguments for option fml\.mcVersion/, ~/Found multiple arguments for option fml\.forgeVersion/, + ~/\/ERROR\] \[net\.minecraft\.client\.Minecraft\/\]: Failed to verify authentication$/, + ~/\/(?:ERROR|FATAL)\] \[net\.minecraftforge\.fml\.network\.simple\.IndexedMessageCodec\/SIMPLENET\]: Received empty payload on channel fml:handshake$/, ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / ] @@ -379,7 +493,7 @@ def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrash log.eachLine('UTF-8') { String line -> lineNumber++ boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ - boolean knownNoise = acceptedForge40LogNoise.any { line =~ it } + boolean knownNoise = acceptedForge45LogNoise.any { line =~ it } boolean fatalText = line.contains('Encountered an unexpected exception') || line.contains('Exception stopping the server') || line.contains('Migration audit failed') || @@ -409,6 +523,8 @@ task runtimeLogScannerTest { File logs = new File(probe, 'logs'); logs.mkdirs() new File(logs, 'latest.log').setText( '[main/ERROR] [FML]: FML appears to be missing any signature data\n' + + '[Render thread/ERROR] [net.minecraft.client.Minecraft/]: Failed to verify authentication\n' + + '[Client thread/ERROR] [net.minecraftforge.fml.network.simple.IndexedMessageCodec/SIMPLENET]: Received empty payload on channel fml:handshake\n' + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) new File(logs, 'latest.log').setText( @@ -463,335 +579,1103 @@ check.dependsOn verifyMineralogyOracleIsolation } } -def surfaceIntegrationClasses = layout.buildDirectory.dir('surface-integration-fixture/classes') -def compileSurfaceIntegrationTestMod = tasks.register('compileSurfaceIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/biomeIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory.set(surfaceIntegrationClasses) - javaCompiler.set(javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(17) - }) - options.release = 17 - options.encoding = 'UTF-8' +def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") +tasks.register('compileClientIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = clientIntegrationClasses + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' +} +tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn tasks.named('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') +} + +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) + destinationDirectory = surfaceIntegrationClasses + sourceCompatibility = '16' + targetCompatibility = '16' + options.encoding = 'UTF-8' } -def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { - dependsOn compileSurfaceIntegrationTestMod - archiveFileName = 'surfaceprobe.jar' - destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' +task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = file("${buildDir}/surface-integration-fixture") + from surfaceIntegrationClasses + from 'src/biomeIntegrationTest/resources' } -def surfaceIntegrationRunDirectory = layout.buildDirectory.dir('surface-integration-run') -def prepareSurfaceIntegrationTest = tasks.register('prepareSurfaceIntegrationTest') { - dependsOn surfaceIntegrationTestModJar - doLast { - File runDirectory = surfaceIntegrationRunDirectory.get().asFile - delete runDirectory - runDirectory.mkdirs() - copy { - from surfaceIntegrationTestModJar.flatMap { it.archiveFile } - into surfaceIntegrationRunDirectory.map { it.dir('mods') } - } - new File(runDirectory, 'server.properties').setText('''\ +def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") +task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { + doLast { + delete surfaceIntegrationRunDirectory + surfaceIntegrationRunDirectory.mkdirs() + copy { + from surfaceIntegrationTestModJar.archiveFile + into new File(surfaceIntegrationRunDirectory, 'mods') + } + new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ level-name=surface-integration-world -level-seed=0 -level-type=minecraft:normal +level-seed=zsjpxah +level-type=default online-mode=false +server-port=0 allow-nether=true generate-structures=false spawn-protection=0 max-tick-time=-1 ''', 'UTF-8') - new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -tasks.configureEach { - if (name == 'runSurfaceIntegrationFresh') { - dependsOn prepareSurfaceIntegrationTest - } else if (name == 'runSurfaceIntegrationReload') { - dependsOn 'runSurfaceIntegrationFresh' - } - if (name == 'runSurfaceIntegrationFresh' || name == 'runSurfaceIntegrationReload') { - doFirst { - ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(workingDir) - } - doLast { - assertRuntimeLogsClean(workingDir, "Forge 45 ${name}", - ext.oreSpawnCrashSnapshot as Set) - } - } -} -def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { - group = 'verification' - description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' - dependsOn 'runSurfaceIntegrationReload' - doLast { - File marker = surfaceIntegrationRunDirectory.get().file( - 'surface-integration-world/surfaceprobe-integration.properties').asFile - if (!marker.isFile()) { - throw new GradleException("Surface integration completion marker is missing: ${marker}") - } - Properties result = new Properties() - marker.withInputStream { result.load(it) } - if (result.getProperty('reload_verified') != 'true') { - throw new GradleException("Surface integration reload was not verified: ${marker}") + new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def surfaceIntegrationFreshProcess = tasks.register('surfaceIntegrationFreshProcess') { + group = 'verification' + dependsOn 'runSurfaceIntegrationFresh' +} +tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { + dependsOn prepareSurfaceIntegrationTest +} +tasks.matching { it.name == 'runSurfaceIntegrationReload' }.all { + mustRunAfter surfaceIntegrationFreshProcess +} +surfaceIntegrationFreshProcess.configure { + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) { + throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") + } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration fresh phase', [] as Set) + } +} +def surfaceIntegrationReloadProcess = tasks.register('surfaceIntegrationReloadProcess') { + group = 'verification' + dependsOn surfaceIntegrationFreshProcess + dependsOn 'runSurfaceIntegrationReload' +} +surfaceIntegrationReloadProcess.configure { + doLast { + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration reload phase', [] as Set) + } +} + +task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { + group = 'verification' + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("Surface integration reload was not verified: ${marker}") + } + logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) + } +} + +check.dependsOn surfaceIntegrationTest + +task syncForge45EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { + group = 'ide' + doLast { + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String ordinaryModClasses = "${mod_id}%%${mainOutput}" + String fixtureOutput = surfaceIntegrationClasses.absolutePath + String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath + String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureResources}" + ['Client', 'Server', 'Data'].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') + if (text.contains('')) { + text = text.replace( + '', + '\r\n' + + " \r\n" + + '') + } + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + if (!text.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + text = text.replace('', + ' \r\n' + + '') + } + launch.setText(text, 'UTF-8') } - logger.lifecycle('Provider surfaces and dynamic-biome geology verified: {} dimensions, {} columns each, fresh + reload', - result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) - } + ['Fresh', 'Reload'].each { String phase -> + File launch = file("runSurfaceIntegration${phase}.launch") + File serverLaunch = file('runServer.launch') + if (!serverLaunch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${serverLaunch}") + } + String text = serverLaunch.getText('UTF-8') + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + text = text.replaceFirst( + //, + { String match, String arguments -> + "" + }) + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + launch.setText(text, 'UTF-8') + } + } } -tasks.named('check') { - dependsOn surfaceIntegrationTest +task verifyForge45EclipseLaunchIsolation { + group = 'verification' + doLast { + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { String launchName -> + File launch = file(launchName) + String launchText = launch.getText('UTF-8') + if (launchText.contains('Mineralogy-') || launchText.contains('biomeIntegrationTest') || + !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") + } + } + } +} + +syncForge45EclipseLaunches.finalizedBy verifyForge45EclipseLaunchIsolation + +tasks.matching { it.name == 'genEclipseRuns' }.all { + finalizedBy syncForge45EclipseLaunches +} + +def packagedSurfaceProbeJar = renamer.classes(tasks.named('surfaceIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('surface-integration-fixture/surfaceprobe-reobf.jar') +} + +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' : expectedMavenCoordinate, + 'Built-On-Java' : '17', + '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/${base.archivesName.get()}-${project.version}.jar") +} + +tasks.named('sourcesJar', Jar) { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-sources', + 'Implementation-Version': project.version + ]) + } +} +tasks.named('javadocJar', Jar) { + filteringCharset = 'UTF-8' + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + '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 tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') } -// Keep every Eclipse launch input on one physical Gradle cache. Mixing Buildship's -// cache with a command-line cache duplicates named Java modules such as FML and Mixin. -def syncEclipseRunClasspaths = tasks.register('syncEclipseRunClasspaths') { - group = 'IDE' - description = 'Aligns Eclipse project, Buildship, and Forge run classpaths with the active Gradle home.' +def expectedReleaseFiles = providers.provider { + String prefix = "${base.archivesName.get()}-${project.version}" + ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') +tasks.register('verifyReleaseConfiguration') { + group = 'verification' doLast { - File classpathDir = layout.buildDirectory.dir('classpath').get().asFile - Set classpathFiles = fileTree(classpathDir) { - include '*_minecraftClasspath.txt' - }.files - if (classpathFiles.isEmpty()) { - throw new GradleException('Missing Forge run classpaths. Run genEclipseRuns before launching from Eclipse.') - } - - File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') - Properties buildship = new Properties() - if (buildshipPreferences.isFile()) { - buildshipPreferences.withInputStream { buildship.load(it) } - } - String configuredGradleHome = buildship.getProperty('connection.gradle.user.home', '').trim() - File eclipseProjectClasspath = file('.classpath') - File eclipseGradleHome = gradle.gradleUserHomeDir.canonicalFile - if (!configuredGradleHome || file(configuredGradleHome).canonicalFile != eclipseGradleHome) { - buildship.setProperty('eclipse.preferences.version', '1') - buildship.setProperty('connection.gradle.user.home', eclipseGradleHome.absolutePath) - buildshipPreferences.parentFile.mkdirs() - buildshipPreferences.withOutputStream { - buildship.store(it, - 'Generated by syncEclipseRunClasspaths; keep Eclipse and command-line caches aligned.') - } - } - File eclipseCache = new File(eclipseGradleHome, 'caches').canonicalFile - File eclipseClasspathDir = file('.settings/orespawn-run-classpaths') - eclipseClasspathDir.mkdirs() - - Closure mapCacheReferences = { String value -> - value.replaceAll(/(?i)[A-Z]:[\\\/]\S*?[\\\/]caches(?=[\\\/])/) { String cacheRoot -> - cacheRoot.contains('/') - ? eclipseCache.absolutePath.replace('\\', '/') - : eclipseCache.absolutePath - } - } - - Map stableClasspaths = [:] - classpathFiles.each { File classpathFile -> - List original = classpathFile.readLines('UTF-8') - List synced = original.collect { String entry -> - String mapped = mapCacheReferences(entry) - String portable = mapped.replace('\\', '/') - int marker = portable.indexOf('/caches/') - if (marker < 0) { - return mapped - } + if (project.mod_version != '4.0.10.119041' + || project.mod_group != expectedMavenGroup + || project.minecraft_version != '1.19.4' + || project.forge_version != '45.4.0' + || project.mapping_channel != 'official' + || project.mapping_version != '1.19.4') { + throw new GradleException('Unexpected OreSpawn 1.19.4 release identity') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '17' || project.gradle_java_version != '17' + || project.java_toolchain_version != '17.0.1+12') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.0.10.119041.jar', + 'OreSpawn-4.0.10.119041-sources.jar', + 'OreSpawn-4.0.10.119041-javadoc.jar' + ] + if (base.archivesName.get() != expectedMavenArtifact + || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { + throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') + } + String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') + expectedPublicArtifacts.each { artifactName -> + if (!ciWorkflow.contains("build/libs/${artifactName}")) { + throw new GradleException("CI does not upload expected public artifact ${artifactName}") + } + } + [ + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains('4.0.10.119041')) { + throw new GradleException("Release identity missing from ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + 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 schemas must remain 6/5') + } + def schema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) + if (!(schema.properties.schema_version.enum as List).contains(4)) { + throw new GradleException('Provider schema must remain version 4') + } + } +} - File target = file(mapped).canonicalFile - if (!target.isFile()) { - throw new GradleException("Missing Eclipse run dependency in configured Gradle cache: ${target}") +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') } + .sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes + byte[] candidate = new File(root, relative).bytes + if (!java.util.Arrays.equals(tracked, candidate)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, + expected, 'generated resources') + assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, + 'META-INF/orespawn/docs'), expected, 'processed resources') + assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), + expected, 'Eclipse bin/main') + new ZipFile(releaseJar.get().output.get().asFile).withCloseable { zip -> + expected.each { relative -> + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals( + new File(trackedDocumentationDirectory, relative).bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Release jar documentation differs at ${relative}") } - return target.absolutePath } - - File stableClasspath = new File(eclipseClasspathDir, classpathFile.name) - stableClasspath.setText(synced.join(System.lineSeparator()) + System.lineSeparator(), 'UTF-8') - stableClasspaths.put(classpathFile.canonicalFile, stableClasspath.canonicalFile) } + } +} - int changedProjectClasspath = 0 - int changedResourceExclusions = 0 - if (eclipseProjectClasspath.isFile()) { - String original = eclipseProjectClasspath.getText('UTF-8') - def parsedClasspath = new XmlParser(false, false).parseText(original) - parsedClasspath.classpathentry.each { entry -> - ['path', 'sourcepath'].each { String attribute -> - String value = entry.attribute(attribute) - if (!value) { - return - } - String mapped = mapCacheReferences(value) - if (mapped == value) { - return +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + 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 } + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } } - File mappedTarget = file(mapped).canonicalFile - if (mappedTarget.exists()) { - entry.attributes().put(attribute, mappedTarget.absolutePath) - changedProjectClasspath = 1 - } else { - // Test-only libraries may have been resolved by command-line Gradle - // but not yet by Buildship. They are not part of the Forge launch - // module path, so retaining the existing valid path is safe. - File existingTarget = file(value).canonicalFile - if (!existingTarget.exists()) { - throw new GradleException("Missing Eclipse project dependency in both Gradle caches: ${mappedTarget}") - } + if (cr) throw new GradleException( + "${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'agent-notes/', 'surfaceprobe', 'clientprobe', 'ci-fixtures/', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.18.2-5.4.0.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden ${forbidden}") } } } - parsedClasspath.classpathentry.findAll { entry -> - entry.attribute('kind') == 'src' && entry.attribute('path') == 'src/main/resources' - }.each { entry -> - List exclusions = (entry.attribute('excluding') ?: '') - .split(/\|/) - .findAll { !it.isEmpty() } - ['META-INF/mods.toml', 'pack.mcmeta'].each { String metadataFile -> - if (exclusions.remove(metadataFile)) { - changedResourceExclusions = 1 - } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', + 'AGENTS.md' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") + } + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[1.19.4,1.20)"')) { + throw new GradleException('Packaged Forge metadata is incorrect') + } + String transformer = zip.getInputStream( + zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List actualRules = transformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() } + .findAll { !it.isEmpty() } + List expectedRules = [ + 'public-f net.minecraft.world.level.chunk.ChunkGenerator f_62137_', + 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator f_188607_', + 'public-f net.minecraft.world.level.biome.Biome f_47438_', + 'public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration f_68128_' + ] + if (actualRules != expectedRules) { + throw new GradleException("Unexpected packaged SRG access transformer: ${actualRules}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry == null ? null : + new Manifest(zip.getInputStream(manifestEntry)).mainAttributes + if (manifest == null + || manifest.getValue('Implementation-Version') != project.mod_version + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('FMLAT') != 'accesstransformer.cfg' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null) { + throw new GradleException('Release manifest is incorrect or volatile') + } + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = new byte[8] + zip.getInputStream(entry).withCloseable { input -> + if (input.read(header) != 8) throw new GradleException("Cannot inspect ${entry.name}") } - if (exclusions.isEmpty()) { - entry.attributes().remove('excluding') - } else { - entry.attributes().put('excluding', exclusions.join('|')) + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 61) { + throw new GradleException("${entry.name} uses class major ${major}, expected 61") } } - if (changedProjectClasspath || changedResourceExclusions) { - eclipseProjectClasspath.setText(groovy.xml.XmlUtil.serialize(parsedClasspath), 'UTF-8') + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> + if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> + if (zip.getEntry('index.html') == null + || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { + throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') + } + } + } +} - parsedClasspath.classpathentry.each { entry -> - ['path', 'sourcepath'].each { String attribute -> - String value = entry.attribute(attribute) - if (value && value.replace('\\', '/').contains('/caches/')) { - File target = file(value).canonicalFile - if (!target.exists()) { - throw new GradleException("Missing Eclipse project dependency in configured Gradle cache: ${target}") - } - } +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + outputs.file(outputFile) + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) { + throw new GradleException('preparedReleaseDir is required') + } + File prepared = file(preparedReleaseDir.get()) + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List actualNames = jars.collect { it.name.toString() }.sort() + if (actualNames != expected) { + throw new GradleException( + "Prepared release jars ${actualNames} do not match ${expected}") + } + if (jars.any { it.length() == 0L }) { + throw new GradleException('Prepared release contains an empty jar') + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + List actual = 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() + if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { + throw new GradleException('Prepared release checksums do not match') } + } +} - File eclipseLaunchDir = file('.eclipse/configurations') - int changedPrepareLaunches = 0 - if (eclipseLaunchDir.isDirectory()) { - boolean windows = System.getProperty('os.name', '').toLowerCase().contains('windows') - File gradleWrapper = file(windows ? 'gradlew.bat' : 'gradlew').canonicalFile - String launcher = windows - ? (System.getenv('ComSpec') ?: new File(System.getenv('SystemRoot') ?: 'C:\\Windows', - 'System32\\cmd.exe').absolutePath) - : '/bin/sh' - String arguments = windows - ? "/d /s /c \"\\\"${gradleWrapper.absolutePath}\\\" copyEclipseResources --console plain\"" - : "-c \"'${gradleWrapper.absolutePath.replace("'", "'\\''")}' copyEclipseResources --console plain\"" - - fileTree(eclipseLaunchDir) { - include '*prepareRun*.launch' - }.files.each { File launchFile -> - StringWriter output = new StringWriter() - def xml = new groovy.xml.MarkupBuilder(output) - xml.mkp.xmlDeclaration(version: '1.0', encoding: 'UTF-8', standalone: 'no') - xml.launchConfiguration(type: 'org.eclipse.ui.externaltools.ProgramLaunchConfigurationType') { - booleanAttribute(key: 'org.eclipse.debug.ui.ATTR_LAUNCH_IN_BACKGROUND', value: 'true') - booleanAttribute(key: 'org.eclipse.ui.externaltools.ATTR_CAPTURE_OUTPUT', value: 'true') - stringAttribute(key: 'org.eclipse.ui.externaltools.ATTR_LOCATION', value: launcher) - booleanAttribute(key: 'org.eclipse.ui.externaltools.ATTR_SHOW_CONSOLE', value: 'true') - stringAttribute(key: 'org.eclipse.ui.externaltools.ATTR_TOOL_ARGUMENTS', value: arguments) - stringAttribute(key: 'org.eclipse.ui.externaltools.ATTR_WORKING_DIRECTORY', - value: projectDir.canonicalPath) - mapAttribute(key: 'org.eclipse.debug.core.environmentVariables') { - mapEntry(key: 'GRADLE_USER_HOME', value: eclipseGradleHome.absolutePath) - mapEntry(key: 'JAVA_HOME', value: System.getProperty('java.home')) +def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') + .orElse('https://invalid.invalid/missing-maven-upload-url') +def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') +def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') +publishing { + publications { + mavenJava(MavenPublication) { + groupId = expectedMavenGroup + artifactId = expectedMavenArtifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + File prepared = file(preparedReleaseDir.get()) + artifact(new File(prepared, expectedReleaseFiles.get()[0])) + artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } + artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } + } else { + 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' } } - String replacement = output.toString() + System.lineSeparator() - if (launchFile.getText('UTF-8') != replacement) { - launchFile.setText(replacement, 'UTF-8') - changedPrepareLaunches++ - } } } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} +tasks.register('verifyMavenCoordinates') { + group = 'verification' + description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + if (!pomFile.isFile()) { + throw new GradleException("Generated Maven POM does not exist: ${pomFile}") + } + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, + project.version.toString()] + if (project.group.toString() != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, " + + "found ${actual.join(':')}") + } + } +} +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { + throw new GradleException('Release publication must use a remote repository') + } + } +} +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn preparedReleaseDir.isPresent() + ? tasks.named('verifyPreparedReleaseArtifacts') + : tasks.named('verifyReleaseArtifacts') +} - int changedLaunches = 0 - int changedTestExclusions = 0 - if (eclipseLaunchDir.isDirectory()) { - File eclipseClasses = file('bin/main').canonicalFile - String modClasses = "${mod_id}%%${eclipseClasses.absolutePath}" - fileTree(eclipseLaunchDir) { - include '*Slim.launch' - }.files.each { File launchFile -> - String original = launchFile.getText('UTF-8') - String synced = mapCacheReferences(original) - stableClasspaths.each { File generated, File stable -> - synced = synced - .replace(generated.absolutePath, stable.absolutePath) - .replace(generated.absolutePath.replace('\\', '/'), stable.absolutePath.replace('\\', '/')) +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true + file.whenMerged { classpath -> + classpath.entries.removeAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + && ['src/main/resources', 'src/generated/resources'].contains(entry.path) + } + if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + 'build/resources/main', 'bin/main')) + } + } + } + synchronizationTasks 'isolateEclipseProductionRuns' +} +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} +tasks.register('configureEclipseBuildship') { + group = 'ide' + doLast { + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + [ + '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' + ].each { key, value -> preferences.setProperty(key, value) } + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, 'Generated by OreSpawn Buildship configuration.') + } + } +} +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + dependsOn tasks.named('genEclipseRuns') + dependsOn syncForge45EclipseLaunches + dependsOn tasks.named('configureEclipseBuildship') + dependsOn prepareEclipseResources + doLast { + [ + 'OreSpawn_Client.launch': 'GradleStart', + 'OreSpawn_Server.launch': 'GradleStartServer' + ].each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile() && launch.getText('UTF-8').contains(mainClass) + && !launch.delete()) { + throw new GradleException("Could not remove obsolete launch ${name}") + } + } + fileTree(project.projectDir) { include 'run*.launch' }.files.each { 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') + } + } +} +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipseClasspath') + dependsOn tasks.named('isolateEclipseProductionRuns') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties buildshipPreferences = new Properties() + prefs.withInputStream { buildshipPreferences.load(it) } + String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath + if (buildshipPreferences.getProperty('connection.gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException( + "Eclipse Buildship must use the validated Gradle home ${expectedGradleHome}") + } + Set legacyLwjglArtifacts = configurations.compileClasspath.resolvedConfiguration + .resolvedArtifacts + .findAll { it.moduleVersion.id.group == 'org.lwjgl.lwjgl' } + .collect { "${it.moduleVersion.id.group}:${it.name}:${it.moduleVersion.id.version}" } + .toSet() + if (!legacyLwjglArtifacts.isEmpty()) { + throw new GradleException( + "Forge 1.19 Eclipse classpath contains legacy LWJGL 2 artifacts: ${legacyLwjglArtifacts}") + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) { + throw new GradleException('Eclipse .classpath was not generated') + } + String eclipseClasspathText = eclipseClasspath.getText('UTF-8') + if (!eclipseClasspathText.contains('path="build/resources/main"') + || eclipseClasspathText.contains('path="src/main/resources"') + || eclipseClasspathText.contains('path="src/generated/resources"')) { + throw new GradleException( + 'Eclipse must consume only Gradle-processed production resources') + } + [ + 'META-INF/mods.toml', + 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md' + ].each { relative -> + if (!new File('bin/main', relative).isFile()) { + throw new GradleException("Eclipse output is missing ${relative}") + } + } + File processedModMetadata = new File(sourceSets.main.output.resourcesDir, + 'META-INF/mods.toml') + File eclipseModMetadata = file('bin/main/META-INF/mods.toml') + if (!java.util.Arrays.equals(processedModMetadata.bytes, eclipseModMetadata.bytes) + || eclipseModMetadata.getText('UTF-8').contains('${version}') + || !eclipseModMetadata.getText('UTF-8').contains( + "version=\"${project.version}\"")) { + throw new GradleException( + 'Eclipse output contains unexpanded or stale Forge mod metadata') + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', + 'surfaceprobe', 'clientprobe', 'junit-', 'opentest4j-', + 'Mineralogy-1.18.2-5.4.0.jar', 'C:\\Users\\John' + ] + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String expectedModClasses = "${mod_id}%%${mainOutput}" + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) throw new GradleException("Missing ${name}") + String contents = launch.getText('UTF-8') + List leaked = forbidden.findAll { contents.contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${name} exposes test/local content: ${leaked}") + } + if (!contents.contains('ATTR_EXCLUDE_TEST_CODE') + || !contents.contains('PROJECT_ATTR" value="OreSpawn"')) { + throw new GradleException("${name} is not a production-only OreSpawn launch") + } + if (!contents.contains("MOD_CLASSES\" value=\"${expectedModClasses}\"")) { + throw new GradleException("${name} lacks Forge 45 merged output discovery") + } + } + } +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' + doLast { + String gradleSource = file('build.gradle').getText('UTF-8') + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') + || !gradleSource.contains('join(File.pathSeparator)') + || !gradleSource.contains('${File.pathSeparator}')) { + throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') + } + } +} + +tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } +tasks.named('check') { dependsOn tasks.named('verifyMavenCoordinates') } + +def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') +def packagedClientJavaExecutable = providers.gradleProperty('packagedClientJavaExecutable') + +def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> + if (!configuredPath.isPresent()) { + throw new GradleException("Pass -P${propertyName}=") + } + File runtime = file(configuredPath.get()) + if (!runtime.isDirectory()) { + throw new GradleException("${propertyName} does not name a directory: ${runtime}") + } + runtime +} + +def packagedSurfaceRunDirectory = file("${buildDir}/packaged-surface-run") +tasks.register('preparePackagedSurfaceIntegration') { + dependsOn releaseJar + dependsOn packagedSurfaceProbeJar + doLast { + delete packagedSurfaceRunDirectory + packagedSurfaceRunDirectory.mkdirs() + copy { + from releaseJar + from packagedSurfaceProbeJar + into new File(packagedSurfaceRunDirectory, 'mods') + } + new File(packagedSurfaceRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(packagedSurfaceRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} +def packagedSurfaceFresh = tasks.register('packagedSurfaceFresh', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedSurfaceIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + File libraries = new File(runtime, 'libraries') + File forgeDirectory = new File(libraries, + 'net/minecraftforge/forge/1.19.4-45.4.0') + File launcher = new File(forgeDirectory, 'forge-1.19.4-45.4.0-server.jar') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + [launcher, sourceArguments, libraries].each { + if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") + } + File absoluteArguments = new File(packagedSurfaceRunDirectory, 'forge-server-args.txt') + String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' + absoluteArguments.setText(sourceArguments.getText('UTF-8') + .replace('libraries/', libraryPath) + .replace('-DlibraryDirectory=libraries', + "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') + workingDir packagedSurfaceRunDirectory + commandLine java17Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', + "@${absoluteArguments.absolutePath}", 'nogui' + } + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) throw new GradleException('Packaged fresh surface marker is missing') + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface fresh', [] as Set) + } +} +def packagedSurfaceReload = tasks.register('packagedSurfaceReload', Exec) { + group = 'verification' + dependsOn packagedSurfaceFresh + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + File libraries = new File(runtime, 'libraries') + File forgeDirectory = new File(libraries, + 'net/minecraftforge/forge/1.19.4-45.4.0') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + File absoluteArguments = new File(packagedSurfaceRunDirectory, 'forge-server-args.txt') + String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' + absoluteArguments.setText(sourceArguments.getText('UTF-8') + .replace('libraries/', libraryPath) + .replace('-DlibraryDirectory=libraries', + "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') + workingDir packagedSurfaceRunDirectory + commandLine java17Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', + "@${absoluteArguments.absolutePath}", 'nogui' + } + doLast { + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface reload', [] as Set) + } +} +tasks.register('packagedSurfaceIntegrationTest') { + group = 'verification' + dependsOn packagedSurfaceReload + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties values = new Properties() + marker.withInputStream { values.load(it) } + if (values.getProperty('reload_verified') != 'true') { + throw new GradleException('Packaged surface reload was not verified') + } + } +} + +def packagedClientRunDirectory = file("${buildDir}/packaged-client-run") +tasks.register('preparePackagedClientIntegration') { + dependsOn releaseJar + dependsOn packagedClientProbeJar + doLast { + delete packagedClientRunDirectory + packagedClientRunDirectory.mkdirs() + copy { + from releaseJar + from packagedClientProbeJar + into new File(packagedClientRunDirectory, 'mods') + } + new File(packagedClientRunDirectory, 'options.txt').setText( + 'fullscreen:false\nlang:en_us\n', 'UTF-8') + File configDirectory = new File(packagedClientRunDirectory, 'config') + configDirectory.mkdirs() + new File(configDirectory, 'orespawn-common.toml').setText('''\ +# Bootstrap fallbacks. Detailed settings live in orespawn-worldgen.json. +[worldgen] + # Master switch for configured terrain replacement. + place_terrain = true + # Allowed Values: GEOME, LEGACY + fallback_geology_mode = "GEOME" + # Range: 4 ~ 32767 + cyano_region_size = 256 + # Range: 1.0 ~ 32767.0 + cyano_layer_reach = 32.0 + # Range: 1 ~ 255 + cyano_layer_thickness = 8 +''', 'UTF-8') + } +} +def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedClientIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, + 'packagedForgeClientRuntime') + String forgeVersionId = ['1.19.4-forge-45.4.0', 'forge-45.4.0'].find { candidate -> + new File(runtime, "versions/${candidate}/${candidate}.json").isFile() + } + if (forgeVersionId == null) { + throw new GradleException('Official client runtime has no Forge 45.4.0 profile') + } + File forgeJsonFile = new File(runtime, + "versions/${forgeVersionId}/${forgeVersionId}.json") + File baseJsonFile = new File(runtime, 'versions/1.19.4/1.19.4.json') + File baseJar = new File(runtime, 'versions/1.19.4/1.19.4.jar') + File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") + [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), + new File(runtime, 'assets'), nativesDirectory].each { + if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") + } + + def slurper = new groovy.json.JsonSlurper() + Map forgeJson = (Map) slurper.parse(forgeJsonFile) + Map baseJson = (Map) slurper.parse(baseJsonFile) + Map classpathByModule = new LinkedHashMap<>() + [baseJson, forgeJson].each { Map metadata -> + ((List) metadata.libraries).each { Map library -> + List rules = (List) library.rules + boolean allowed = rules == null || rules.isEmpty() + if (rules != null) { + rules.each { Map rule -> + Map os = (Map) rule.os + boolean matches = os == null + || (os.name == 'windows' + && (os.arch == null || os.arch == System.getProperty('os.arch'))) + if (matches) allowed = rule.action == 'allow' + } } - synced = synced.replaceFirst( - //) { - "" + if (!allowed) return + String relative = (String) ((Map) ((Map) library.downloads).artifact).path + File artifact = new File(runtime, "libraries/${relative}") + if (!artifact.isFile()) { + throw new GradleException("Missing official client library: ${artifact}") } - String excludeTestKey = 'org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE' - String excludeTestAttribute = - "" - String beforeTestExclusion = synced - if (synced.contains("key=\"${excludeTestKey}\"")) { - synced = synced.replaceFirst( - //, - excludeTestAttribute) - } else { - int launchHeaderEnd = synced.indexOf('\n', synced.indexOf(' coordinates = ((String) library.name).split(':') as List + String module = coordinates.size() >= 2 + ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name + if (coordinates.size() >= 4) { + // Since 1.19 Mojang lists native classifiers as separate libraries. + // Keep each classifier alongside its base module instead of replacing it. + module += ":${coordinates.subList(3, coordinates.size()).join(':')}" } + classpathByModule.put(module, artifact) } } + List classpathFiles = new ArrayList<>(classpathByModule.values()) + classpathFiles.add(baseJar) + + String libraryDirectory = new File(runtime, 'libraries').absolutePath + List forgeJvmArguments = ((List) ((Map) forgeJson.arguments).jvm) + .collect { String argument -> + argument.replace('${library_directory}', libraryDirectory) + .replace('${classpath_separator}', File.pathSeparator) + .replace('${version_name}', (String) forgeJson.inheritsFrom) + } - // ForgeGradle's launch group lets Java start even if its Buildship - // preparation step changed caches or failed. Publish the synchronized - // Java launch directly; Eclipse already copies resources into bin/main. - int changedLaunchGroups = 0 - fileTree(projectDir) { - include 'run*.launch' - }.files.each { File launchFile -> - String runName = launchFile.name.substring(0, launchFile.name.length() - '.launch'.length()) - File slimLaunch = new File(eclipseLaunchDir, "${project.name} - ${runName}Slim.launch") - if (!slimLaunch.isFile()) { - return - } - String replacement = slimLaunch.getText('UTF-8') - if (launchFile.getText('UTF-8') != replacement) { - launchFile.setText(replacement, 'UTF-8') - changedLaunchGroups++ + List gameArguments = [] + gameArguments.addAll((List) ((Map) forgeJson.arguments).game) + gameArguments.addAll([ + '--username', 'OreSpawnValidation', + '--version', (String) forgeJson.id, + '--gameDir', packagedClientRunDirectory.absolutePath, + '--assetsDir', new File(runtime, 'assets').absolutePath, + '--assetIndex', (String) ((Map) baseJson.assetIndex).id, + '--uuid', '00000000-0000-0000-0000-000000000001', + '--accessToken', 'validation-token', + '--userType', 'legacy', + '--versionType', 'release', + '--width', '854', '--height', '480' + ]) + workingDir packagedClientRunDirectory + File clientJava = packagedClientJavaExecutable.isPresent() + ? file(packagedClientJavaExecutable.get()) + : java17Launcher.get().executablePath.asFile + if (!clientJava.isFile()) { + throw new GradleException("packagedClientJavaExecutable does not name a Java executable: ${clientJava}") + } + List clientCommand = [ + clientJava.absolutePath, + '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', + "-Djava.library.path=${nativesDirectory.absolutePath}", + '-Dminecraft.launcher.brand=orespawn-validation', + '-Dminecraft.launcher.version=1' + ] + clientCommand.addAll(forgeJvmArguments) + clientCommand.addAll([ + '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), + (String) forgeJson.mainClass + ]) + clientCommand.addAll(gameArguments) + commandLine(clientCommand) + } +} +tasks.register('packagedClientIntegrationTest') { + group = 'verification' + dependsOn packagedClientProcess + doLast { + File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException('Packaged client completion marker is missing') + } + Properties values = new Properties() + marker.withInputStream { values.load(it) } + ['world_settings_opened', 'long_editor_roundtrip', + 'first_world_rendered', 'reload_rendered'].each { key -> + if (values.getProperty(key) != 'true') { + throw new GradleException("Packaged client failed ${key}: ${values}") } } - logger.lifecycle("Eclipse runtime paths aligned with ${eclipseCache} (${stableClasspaths.size()} Forge classpaths, ${changedProjectClasspath} project classpath, ${changedResourceExclusions} metadata exclusion, ${changedPrepareLaunches} prepare launches, ${changedLaunches} slim launches, ${changedTestExclusions} test exclusions, ${changedLaunchGroups} direct project launches updated)") + assertDocumentationTree(new File(packagedClientRunDirectory, + 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') + assertRuntimeLogsClean(packagedClientRunDirectory, + 'packaged client', [] as Set) } } -tasks.configureEach { - if (name in ['copyEclipseResources', 'genEclipseRuns', 'eclipse', - 'prepareRunClient', 'prepareRunServer', 'prepareRunData', 'prepareRunGameTestServer']) { - finalizedBy syncEclipseRunClasspaths - } +tasks.register('packagedRuntimeIntegrationTest') { + group = 'verification' + description = 'Runs the exact reobfuscated jars in official Forge 45 server and client runtimes.' + dependsOn tasks.named('packagedSurfaceIntegrationTest') + dependsOn tasks.named('packagedClientIntegrationTest') } diff --git a/ci-fixtures/README.md b/ci-fixtures/README.md new file mode 100644 index 00000000..d4c2920d --- /dev/null +++ b/ci-fixtures/README.md @@ -0,0 +1,13 @@ +# OreSpawn 1.19.4 CI fixtures + +These immutable inputs make the legacy-Mineralogy compatibility gate +self-contained. They are test oracles only and must never enter a Gradle +dependency configuration, Eclipse launch, or published OreSpawn artifact. + +Minecraft 1.19.4 has no published Mineralogy lineage of its own. The last +published legacy engine, `Mineralogy-1.18.2-5.4.0.jar`, was reproduced from the exact historical +MinecraftMineralogy source commit +`6675bac3cb9c1df138ce9b359c0b47d7a797cdfc` using Java 17 and the original +ForgeGradle 6 / Gradle 8.8 build. Its checksum is sealed in `SHA256SUMS` +and validated before the oracle is loaded through the isolated test +classloader as the mandatory legacy-configuration oracle for this target. diff --git a/ci-fixtures/SHA256SUMS b/ci-fixtures/SHA256SUMS new file mode 100644 index 00000000..b7c6864e --- /dev/null +++ b/ci-fixtures/SHA256SUMS @@ -0,0 +1 @@ +CCA84E9270585478B08F54BA091AD56AB2CB390386C650ED78B2673DC57403EB artifacts/Mineralogy-1.18.2-5.4.0.jar diff --git a/ci-fixtures/artifacts/Mineralogy-1.18.2-5.4.0.jar b/ci-fixtures/artifacts/Mineralogy-1.18.2-5.4.0.jar new file mode 100644 index 00000000..3a6dc189 Binary files /dev/null and b/ci-fixtures/artifacts/Mineralogy-1.18.2-5.4.0.jar differ diff --git a/ci-fixtures/tools/LICENSE-MAVENIZER.txt b/ci-fixtures/tools/LICENSE-MAVENIZER.txt new file mode 100644 index 00000000..8000a6fa --- /dev/null +++ b/ci-fixtures/tools/LICENSE-MAVENIZER.txt @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random + Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/ci-fixtures/tools/README.md b/ci-fixtures/tools/README.md new file mode 100644 index 00000000..e69db45b --- /dev/null +++ b/ci-fixtures/tools/README.md @@ -0,0 +1,49 @@ +# ForgeGradle 7 Mavenizer compatibility fixture + +This directory contains a build-only derivative of MinecraftForge's +MinecraftMavenizer `0.5.21`. It is used only while ForgeGradle prepares the +exact Forge `1.19.4-45.4.0` development dependency and is excluded from every +OreSpawn publication artifact. + +## Provenance and licence + +- Upstream: +- Exact source commit: `6968241ce7a0a902cdc1c534b976e8373a423091` +- Upstream version: `0.5.21` +- Licence: LGPL-2.1-only; see `LICENSE-MAVENIZER.txt` +- OreSpawn derivative patch: `minecraft-mavenizer-0.5.21-orespawn-compat.patch` +- Embedded/external rule manifest: `minecraft-source-compatibility.json` + +The patch adds a target-aware compatibility stage before Mavenizer recompiles +decompiled sources. Rules run only for an exact Maven artifact listed in the +manifest. Each rule requires one exact source file, one exact record +declaration and one accessor in that record. Missing, partial, duplicate or +ambiguous states fail preparation. Targets without an explicit rule set are +left unchanged. + +For Forge `1.19.4-45.4.0`, the manifest removes the eight explicit accessors +that duplicate compiler-generated record accessors in `Holder.Direct`, +`OptionInstance` and `MemoryCondition`. The implicit record methods have the +same public contract. A marker beside Mavenizer's output records the target, +manifest SHA-256 and applied rule IDs. Reprocessing already-patched sources is +validated and idempotent. + +The derivative also propagates Gradle offline mode when the build sets +`ORESPAWN_MAVENIZER_OFFLINE=true`. Mavenizer itself runs on Java 25; OreSpawn +and Minecraft 1.19.4 continue to compile for Java 17. + +## Rebuild + +1. Clone the upstream repository and detach at + `6968241ce7a0a902cdc1c534b976e8373a423091`. +2. Apply `minecraft-mavenizer-0.5.21-orespawn-compat.patch` with `git am`. +3. Set `JAVA_HOME` to Temurin Java 25. +4. Run `./gradlew clean build --no-daemon` (or `gradlew.bat` on Windows). +5. Copy `build/libs/minecraft-mavenizer-0.5.21.jar` to + `minecraft-mavenizer-0.5.21-orespawn-compat.jar`. +6. Run OreSpawn's `verifyMavenizerCompatibilityFixture` task. The build script + contains the authoritative checksums and also verifies the embedded + manifest, Java class version and licence/provenance files. + +The sealed derivative was built with Eclipse Temurin `25.0.3+9` and Gradle +`9.1.0` from the upstream wrapper. diff --git a/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar new file mode 100644 index 00000000..cc781cdb Binary files /dev/null and b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar differ diff --git a/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch new file mode 100644 index 00000000..28951fba --- /dev/null +++ b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch @@ -0,0 +1,425 @@ +From 10279f8ab1adadb87b56f36c85dbda3cf64e62b3 Mon Sep 17 00:00:00 2001 +From: OreSpawn Build Fixture +Date: Fri, 28 Aug 2026 15:21:10 +0100 +Subject: [PATCH] Add target-aware source compatibility rules + +--- + build.gradle | 2 +- + .../net/minecraftforge/mcmaven/cli/Main.java | 18 +- + .../minecraftforge/mcmaven/cli/MavenTask.java | 3 + + .../mcmaven/impl/util/ProcessUtils.java | 2 + + .../impl/util/SourceCompatibilityPatcher.java | 241 ++++++++++++++++++ + .../minecraft-source-compatibility.json | 58 +++++ + 6 files changed, 322 insertions(+), 2 deletions(-) + create mode 100644 src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java + create mode 100644 src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json + +diff --git a/build.gradle b/build.gradle +index 439c211..9d99d81 100644 +--- a/build.gradle ++++ b/build.gradle +@@ -17,7 +17,7 @@ plugins { + gradleutils.displayName = 'Minecraft Mavenizer' + description = 'A pure-blooded Java tool to generate a maven repository for Minecraft artifacts.' + group = 'net.minecraftforge' +-version = gitversion.tagOffset ++version = '0.5.21' + + println "Version: $version" + +diff --git a/src/main/java/net/minecraftforge/mcmaven/cli/Main.java b/src/main/java/net/minecraftforge/mcmaven/cli/Main.java +index eeda8a0..ad0fe93 100644 +--- a/src/main/java/net/minecraftforge/mcmaven/cli/Main.java ++++ b/src/main/java/net/minecraftforge/mcmaven/cli/Main.java +@@ -12,6 +12,7 @@ import static net.minecraftforge.mcmaven.impl.Mavenizer.LOGGER; + + import java.time.Duration; + import java.util.ArrayList; ++import java.util.Arrays; + + public class Main { + private static final String DISPLAY_NAME = "Minecraft Mavenizer"; +@@ -20,6 +21,8 @@ public class Main { + try { + LOGGER.capture(); + LOGGER.info(JarVersionInfo.of(DISPLAY_NAME, Main.class).implementation()); ++ LOGGER.info("OreSpawn Mavenizer compatibility runtime: Java " + Runtime.version() ++ + " (" + System.getProperty("java.vendor") + ")"); + run(args); + } catch (Throwable e) { + LOGGER.release(); +@@ -36,7 +39,8 @@ public class Main { + LOGGER.getInfo().printf(", took %d:%02d.%03d%n", time.toMinutesPart(), time.toSecondsPart(), time.toMillisPart()); + } + +- private static void run(String[] args) throws Exception { ++ private static void run(String[] suppliedArgs) throws Exception { ++ var args = withOfflinePropagation(suppliedArgs); + var parser = new OptionParser(); + parser.allowsUnrecognizedOptions(); + var tasks = Tasks.values(); +@@ -88,4 +92,16 @@ public class Main { + Tasks.MAVEN.callback.run(args, false); + } + } ++ ++ private static String[] withOfflinePropagation(String[] suppliedArgs) { ++ if (!"true".equalsIgnoreCase(System.getenv("ORESPAWN_MAVENIZER_OFFLINE")) ++ || Arrays.asList(suppliedArgs).contains("--offline")) { ++ return suppliedArgs; ++ } ++ ++ var propagated = Arrays.copyOf(suppliedArgs, suppliedArgs.length + 1); ++ propagated[suppliedArgs.length] = "--offline"; ++ LOGGER.info("OreSpawn Mavenizer compatibility: propagated Gradle offline mode"); ++ return propagated; ++ } + } +diff --git a/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java b/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java +index 825820e..39bb9b3 100644 +--- a/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java ++++ b/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java +@@ -21,6 +21,7 @@ import net.minecraftforge.mcmaven.impl.cache.Cache; + import net.minecraftforge.mcmaven.impl.mappings.Mappings; + import net.minecraftforge.mcmaven.impl.util.Artifact; + import net.minecraftforge.mcmaven.impl.util.Constants; ++import net.minecraftforge.mcmaven.impl.util.SourceCompatibilityPatcher; + + import static net.minecraftforge.mcmaven.impl.Mavenizer.LOGGER; + +@@ -194,6 +195,8 @@ class MavenTask { + if (artifact.getVersion() == null) + artifact = artifact.withVersion(options.valueOf(versionO)); + ++ SourceCompatibilityPatcher.selectTarget(artifact); ++ + var mappings = getMappings(options, mappingsO, parchmentO); + + var foreignRepositories = new HashMap(); +diff --git a/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java b/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java +index 9fa2888..d8c14fd 100644 +--- a/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java ++++ b/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java +@@ -305,6 +305,8 @@ public final class ProcessUtils { + throw new RuntimeException("Failed to extract source jar: " + sourcesJar.getAbsolutePath(), e); + } + ++ SourceCompatibilityPatcher.apply(sourcesOutput.toPath(), outputJar.toPath()); ++ + // track source files + var sourcePath = new StringBuilder(); + var nonSourceFiles = new ArrayList(); +diff --git a/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java b/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java +new file mode 100644 +index 0000000..2cb07e0 +--- /dev/null ++++ b/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java +@@ -0,0 +1,241 @@ ++/* ++ * Copyright (c) Forge Development LLC and contributors ++ * SPDX-License-Identifier: LGPL-2.1-only ++ */ ++package net.minecraftforge.mcmaven.impl.util; ++ ++import com.google.gson.Gson; ++import java.io.IOException; ++import java.io.InputStream; ++import java.nio.charset.StandardCharsets; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.nio.file.StandardCopyOption; ++import java.security.MessageDigest; ++import java.security.NoSuchAlgorithmException; ++import java.util.HashSet; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Objects; ++import java.util.stream.Collectors; ++ ++import static net.minecraftforge.mcmaven.impl.Mavenizer.LOGGER; ++ ++/** Applies exact, target-scoped source compatibility rules before recompilation. */ ++public final class SourceCompatibilityPatcher { ++ public static final String MANIFEST_RESOURCE = "META-INF/orespawn/minecraft-source-compatibility.json"; ++ private static final Gson GSON = new Gson(); ++ private static volatile String selectedArtifact; ++ ++ private SourceCompatibilityPatcher() {} ++ ++ public static void selectTarget(Artifact artifact) { ++ selectedArtifact = artifact.getDescriptor(); ++ } ++ ++ public static void apply(Path sourceRoot, Path outputJar) { ++ var artifact = selectedArtifact; ++ if (artifact == null) ++ throw new IllegalStateException("No Mavenizer target artifact was selected before source recompilation"); ++ ++ var manifestBytes = readManifest(); ++ var manifestHash = sha256(manifestBytes); ++ var manifest = GSON.fromJson(new String(manifestBytes, StandardCharsets.UTF_8), RuleManifest.class); ++ if (manifest == null || manifest.schema != 1 || manifest.targets == null) ++ throw new IllegalStateException("Unsupported or incomplete OreSpawn Mavenizer compatibility manifest"); ++ ++ var target = manifest.targets.get(artifact); ++ if (target == null) { ++ LOGGER.info("OreSpawn Mavenizer compatibility: no rules for " + artifact); ++ return; ++ } ++ ++ validateTarget(target); ++ var sourceByFile = new LinkedHashMap(); ++ var present = 0; ++ var absent = 0; ++ for (var rule : target.rules) { ++ var file = sourceRoot.resolve(rule.file).normalize(); ++ if (!file.startsWith(sourceRoot.normalize())) ++ throw new IllegalStateException("Compatibility rule escapes source root: " + rule.id); ++ if (!Files.isRegularFile(file)) ++ throw new IllegalStateException("Compatibility source file is missing for " + rule.id + ": " + rule.file); ++ ++ var source = sourceByFile.computeIfAbsent(file, ++ ignored -> readUtf8(file).replace("\r\n", "\n")); ++ requireCount(source, rule.recordDeclaration, 1, "record declaration", rule); ++ var accessorCount = countInRecord(source, rule); ++ if (accessorCount == 1) ++ present++; ++ else if (accessorCount == 0) ++ absent++; ++ else ++ throw new IllegalStateException("Ambiguous accessor match for " + rule.id + ": expected at most 1, found " + accessorCount); ++ } ++ ++ if (present != 0 && absent != 0) ++ throw new IllegalStateException("Partial compatibility state for " + artifact + ": " + present + " present, " + absent + " absent"); ++ ++ if (present == target.expectedApplications) { ++ for (var rule : target.rules) { ++ var file = sourceRoot.resolve(rule.file).normalize(); ++ var patched = replaceInRecord(sourceByFile.get(file), rule); ++ sourceByFile.put(file, patched); ++ } ++ sourceByFile.forEach(SourceCompatibilityPatcher::writeUtf8); ++ writeMarker(outputJar, artifact, manifestHash, target.rules); ++ LOGGER.info("OreSpawn Mavenizer compatibility: applied " + present + " rule(s) for " + artifact ++ + " [" + ruleIds(target.rules) + "] manifest SHA-256 " + manifestHash); ++ } else if (absent == target.expectedApplications) { ++ writeMarker(outputJar, artifact, manifestHash, target.rules); ++ LOGGER.info("OreSpawn Mavenizer compatibility: verified " + absent + " rule(s) already applied for " + artifact ++ + " [" + ruleIds(target.rules) + "] manifest SHA-256 " + manifestHash); ++ } else { ++ throw new IllegalStateException("Compatibility manifest expected " + target.expectedApplications ++ + " applications for " + artifact + " but defines " + target.rules.size()); ++ } ++ } ++ ++ private static void validateTarget(TargetRuleSet target) { ++ if (target.expectedApplications <= 0 || target.rules == null ++ || target.rules.size() != target.expectedApplications) { ++ throw new IllegalStateException("Compatibility target has an invalid expected application count"); ++ } ++ var ids = new HashSet(); ++ for (var rule : target.rules) { ++ Objects.requireNonNull(rule.id, "Compatibility rule id"); ++ Objects.requireNonNull(rule.file, "Compatibility rule file"); ++ Objects.requireNonNull(rule.recordDeclaration, "Compatibility record declaration"); ++ Objects.requireNonNull(rule.accessor, "Compatibility accessor"); ++ if (!ids.add(rule.id)) ++ throw new IllegalStateException("Duplicate compatibility rule id: " + rule.id); ++ } ++ } ++ ++ private static byte[] readManifest() { ++ try (InputStream in = SourceCompatibilityPatcher.class.getClassLoader().getResourceAsStream(MANIFEST_RESOURCE)) { ++ if (in == null) ++ throw new IllegalStateException("Missing embedded compatibility manifest: " + MANIFEST_RESOURCE); ++ return in.readAllBytes(); ++ } catch (IOException e) { ++ throw new IllegalStateException("Could not read embedded compatibility manifest", e); ++ } ++ } ++ ++ private static String readUtf8(Path file) { ++ try { ++ return Files.readString(file, StandardCharsets.UTF_8); ++ } catch (IOException e) { ++ throw new IllegalStateException("Could not read compatibility source " + file, e); ++ } ++ } ++ ++ private static void writeUtf8(Path file, String content) { ++ try { ++ Files.writeString(file, content, StandardCharsets.UTF_8); ++ } catch (IOException e) { ++ throw new IllegalStateException("Could not write compatibility source " + file, e); ++ } ++ } ++ ++ private static void writeMarker(Path outputJar, String artifact, String manifestHash, List rules) { ++ var marker = Path.of(outputJar.toString() + ".orespawn-compatibility"); ++ var temp = Path.of(marker.toString() + ".tmp"); ++ var content = "artifact=" + artifact + "\nmanifestSha256=" + manifestHash + "\nrules=" + ruleIds(rules) + "\n"; ++ try { ++ Files.writeString(temp, content, StandardCharsets.UTF_8); ++ try { ++ Files.move(temp, marker, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); ++ } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { ++ Files.move(temp, marker, StandardCopyOption.REPLACE_EXISTING); ++ } ++ } catch (IOException e) { ++ throw new IllegalStateException("Could not write compatibility marker " + marker, e); ++ } ++ } ++ ++ private static String ruleIds(List rules) { ++ return rules.stream().map(rule -> rule.id).collect(Collectors.joining(",")); ++ } ++ ++ private static void requireCount(String source, String needle, int expected, String description, Rule rule) { ++ var actual = count(source, needle); ++ if (actual != expected) ++ throw new IllegalStateException("Invalid " + description + " match for " + rule.id ++ + ": expected " + expected + ", found " + actual); ++ } ++ ++ private static int count(String source, String needle) { ++ var count = 0; ++ var from = 0; ++ while ((from = source.indexOf(needle, from)) >= 0) { ++ count++; ++ from += needle.length(); ++ } ++ return count; ++ } ++ ++ private static int countInRecord(String source, Rule rule) { ++ var bounds = recordBounds(source, rule); ++ return count(source.substring(bounds[0], bounds[1]), rule.accessor); ++ } ++ ++ private static String replaceInRecord(String source, Rule rule) { ++ var bounds = recordBounds(source, rule); ++ var body = source.substring(bounds[0], bounds[1]); ++ var count = count(body, rule.accessor); ++ if (count != 1) ++ throw new IllegalStateException("Accessor state changed while applying " + rule.id ++ + ": expected 1, found " + count); ++ var patchedBody = body.replace(rule.accessor, ""); ++ if (count(patchedBody, rule.accessor) != 0) ++ throw new IllegalStateException("Accessor remained after applying " + rule.id); ++ return source.substring(0, bounds[0]) + patchedBody + source.substring(bounds[1]); ++ } ++ ++ private static int[] recordBounds(String source, Rule rule) { ++ var declaration = source.indexOf(rule.recordDeclaration); ++ var open = source.indexOf('{', declaration + rule.recordDeclaration.length()); ++ if (declaration < 0 || open < 0) ++ throw new IllegalStateException("Could not locate record body for " + rule.id); ++ var depth = 0; ++ for (var index = open; index < source.length(); index++) { ++ var character = source.charAt(index); ++ if (character == '{') ++ depth++; ++ else if (character == '}' && --depth == 0) ++ return new int[] {open, index + 1}; ++ } ++ throw new IllegalStateException("Unterminated record body for " + rule.id); ++ } ++ ++ private static String sha256(byte[] bytes) { ++ try { ++ var digest = MessageDigest.getInstance("SHA-256").digest(bytes); ++ var result = new StringBuilder(digest.length * 2); ++ for (byte value : digest) ++ result.append(String.format("%02X", value)); ++ return result.toString(); ++ } catch (NoSuchAlgorithmException e) { ++ throw new IllegalStateException("SHA-256 is unavailable", e); ++ } ++ } ++ ++ private static final class RuleManifest { ++ int schema; ++ Map targets; ++ } ++ ++ private static final class TargetRuleSet { ++ int expectedApplications; ++ List rules; ++ } ++ ++ private static final class Rule { ++ String id; ++ String file; ++ String recordDeclaration; ++ String accessor; ++ } ++} +diff --git a/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json b/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json +new file mode 100644 +index 0000000..462d571 +--- /dev/null ++++ b/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json +@@ -0,0 +1,58 @@ ++{ ++ "schema": 1, ++ "targets": { ++ "net.minecraftforge:forge:1.19.4-45.4.0": { ++ "expectedApplications": 8, ++ "rules": [ ++ { ++ "id": "forge-1.19.4-holder-direct-value", ++ "file": "net/minecraft/core/Holder.java", ++ "recordDeclaration": "public static record Direct(T value) implements Holder", ++ "accessor": "\n public T value() {\n return this.value;\n }\n" ++ }, ++ { ++ "id": "forge-1.19.4-option-alt-enum-value-setter", ++ "file": "net/minecraft/client/OptionInstance.java", ++ "recordDeclaration": "public static record AltEnum(List values, List altValues, BooleanSupplier altCondition, OptionInstance.CycleableValueSet.ValueSetter valueSetter, Codec codec)", ++ "accessor": "\n public OptionInstance.CycleableValueSet.ValueSetter valueSetter() {\n return this.valueSetter;\n }\n" ++ }, ++ { ++ "id": "forge-1.19.4-option-alt-enum-codec", ++ "file": "net/minecraft/client/OptionInstance.java", ++ "recordDeclaration": "public static record AltEnum(List values, List altValues, BooleanSupplier altCondition, OptionInstance.CycleableValueSet.ValueSetter valueSetter, Codec codec)", ++ "accessor": "\n public Codec codec() {\n return this.codec;\n }\n" ++ }, ++ { ++ "id": "forge-1.19.4-option-enum-codec", ++ "file": "net/minecraft/client/OptionInstance.java", ++ "recordDeclaration": "public static record Enum(List values, Codec codec)", ++ "accessor": "\n public Codec codec() {\n return this.codec;\n }\n" ++ }, ++ { ++ "id": "forge-1.19.4-option-lazy-enum-codec", ++ "file": "net/minecraft/client/OptionInstance.java", ++ "recordDeclaration": "public static record LazyEnum(Supplier> values, Function> validateValue, Codec codec)", ++ "accessor": "\n public Codec codec() {\n return this.codec;\n }\n" ++ }, ++ { ++ "id": "forge-1.19.4-memory-absent-memory", ++ "file": "net/minecraft/world/entity/ai/behavior/declarative/MemoryCondition.java", ++ "recordDeclaration": "public static record Absent(MemoryModuleType memory)", ++ "accessor": "\n public MemoryModuleType memory() {\n return this.memory;\n }\n" ++ }, ++ { ++ "id": "forge-1.19.4-memory-present-memory", ++ "file": "net/minecraft/world/entity/ai/behavior/declarative/MemoryCondition.java", ++ "recordDeclaration": "public static record Present(MemoryModuleType memory)", ++ "accessor": "\n public MemoryModuleType memory() {\n return this.memory;\n }\n" ++ }, ++ { ++ "id": "forge-1.19.4-memory-registered-memory", ++ "file": "net/minecraft/world/entity/ai/behavior/declarative/MemoryCondition.java", ++ "recordDeclaration": "public static record Registered(MemoryModuleType memory)", ++ "accessor": "\n public MemoryModuleType memory() {\n return this.memory;\n }\n" ++ } ++ ] ++ } ++ } ++} +-- +2.55.0.windows.3 + diff --git a/ci-fixtures/tools/minecraft-source-compatibility.json b/ci-fixtures/tools/minecraft-source-compatibility.json new file mode 100644 index 00000000..462d5714 --- /dev/null +++ b/ci-fixtures/tools/minecraft-source-compatibility.json @@ -0,0 +1,58 @@ +{ + "schema": 1, + "targets": { + "net.minecraftforge:forge:1.19.4-45.4.0": { + "expectedApplications": 8, + "rules": [ + { + "id": "forge-1.19.4-holder-direct-value", + "file": "net/minecraft/core/Holder.java", + "recordDeclaration": "public static record Direct(T value) implements Holder", + "accessor": "\n public T value() {\n return this.value;\n }\n" + }, + { + "id": "forge-1.19.4-option-alt-enum-value-setter", + "file": "net/minecraft/client/OptionInstance.java", + "recordDeclaration": "public static record AltEnum(List values, List altValues, BooleanSupplier altCondition, OptionInstance.CycleableValueSet.ValueSetter valueSetter, Codec codec)", + "accessor": "\n public OptionInstance.CycleableValueSet.ValueSetter valueSetter() {\n return this.valueSetter;\n }\n" + }, + { + "id": "forge-1.19.4-option-alt-enum-codec", + "file": "net/minecraft/client/OptionInstance.java", + "recordDeclaration": "public static record AltEnum(List values, List altValues, BooleanSupplier altCondition, OptionInstance.CycleableValueSet.ValueSetter valueSetter, Codec codec)", + "accessor": "\n public Codec codec() {\n return this.codec;\n }\n" + }, + { + "id": "forge-1.19.4-option-enum-codec", + "file": "net/minecraft/client/OptionInstance.java", + "recordDeclaration": "public static record Enum(List values, Codec codec)", + "accessor": "\n public Codec codec() {\n return this.codec;\n }\n" + }, + { + "id": "forge-1.19.4-option-lazy-enum-codec", + "file": "net/minecraft/client/OptionInstance.java", + "recordDeclaration": "public static record LazyEnum(Supplier> values, Function> validateValue, Codec codec)", + "accessor": "\n public Codec codec() {\n return this.codec;\n }\n" + }, + { + "id": "forge-1.19.4-memory-absent-memory", + "file": "net/minecraft/world/entity/ai/behavior/declarative/MemoryCondition.java", + "recordDeclaration": "public static record Absent(MemoryModuleType memory)", + "accessor": "\n public MemoryModuleType memory() {\n return this.memory;\n }\n" + }, + { + "id": "forge-1.19.4-memory-present-memory", + "file": "net/minecraft/world/entity/ai/behavior/declarative/MemoryCondition.java", + "recordDeclaration": "public static record Present(MemoryModuleType memory)", + "accessor": "\n public MemoryModuleType memory() {\n return this.memory;\n }\n" + }, + { + "id": "forge-1.19.4-memory-registered-memory", + "file": "net/minecraft/world/entity/ai/behavior/declarative/MemoryCondition.java", + "recordDeclaration": "public static record Registered(MemoryModuleType memory)", + "accessor": "\n public MemoryModuleType memory() {\n return this.memory;\n }\n" + } + ] + } + } +} diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 04e7b789..a62c49fb 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -12,5 +12,6 @@ Use the focused guides for implementation details: - [BIOMES.md](BIOMES.md) and [DIMENSIONS.md](DIMENSIONS.md) for world integration; - [TEMPLATES.md](TEMPLATES.md) for selectable world styles; - [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; -- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified versioning and branch-release convention; +- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified + versioning, skipped functional releases, and branch-release convention; - [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/API.md b/docs/API.md index a4c26584..1835f8b0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,7 +12,7 @@ runtime. In `mods.toml` use a mandatory dependency, for example: [[dependencies.examplemod]] modId="orespawn" mandatory=true -versionRange="[4.0.0,5.0.0)" +versionRange="[4.0.6,5.0.0)" ordering="AFTER" side="BOTH" ``` diff --git a/docs/BIOMES.md b/docs/BIOMES.md index f71c46c7..2c9e9377 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -124,6 +124,13 @@ lets OreSpawn replace the actual exposed ground while preserving later trees, plants, authored structures, and block entities. In ceiling dimensions, `ceiling_block` applies to the roof underside and does not replace the roof top. +Provider-declared `terrain_dimensions.host_blocks` are resolved by one terrain +scan at the start of `LOCAL_MODIFICATIONS`, immediately before provider +surfaces. Matching natural blocks already present in base terrain are eligible +for geology; matching blocks authored by later structure or vegetation stages +are not. Air, fluids, bedrock, and block-entity states remain protected even if +a provider mistakenly lists their block IDs as terrain hosts. + Surface correction is generation-only. Installing or updating OreSpawn does not rewrite already generated chunks; travel into new terrain to see a changed provider surface definition. diff --git a/docs/README.md b/docs/README.md index e63cf93c..9fe8ad40 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Choose the guide that matches what you are doing: - [Dimensions](DIMENSIONS.md) - [Migration](MIGRATION.md) - [Troubleshooting](TROUBLESHOOTING.md) +- [Versioning and release conventions](VERSIONS.md) - [Compact instructions for coding agents](AGENTS.md) Validated examples are in `examples/`; JSON Schemas are in `schemas/`. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 005f28db..2f1a298a 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -49,9 +49,15 @@ version. Examples: -| Minecraft | Loader | Target | Full OreSpawn 4.0.6 version | +| Minecraft | Loader | Target | Example full OreSpawn version | | --- | --- | ---: | --- | | 1.13.2 | Forge | `113021` | `4.0.6.113021` | +| 1.14.4 | Forge | `114041` | `4.0.8.114041` | +| 1.15.2 | Forge | `115021` | `4.0.9.115021` | +| 1.16.5 | Forge | `116051` | `4.0.9.116051` | +| 1.17.1 | Forge | `117011` | `4.0.9.117011` | +| 1.18.2 | Forge | `118021` | `4.0.10.118021` | +| 1.19.4 | Forge | `119041` | `4.0.10.119041` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | | 1.21.11 | Forge | `121111` | `4.0.6.121111` | | 26.1.2 | Forge | `2601021` | `4.0.6.2601021` | @@ -138,12 +144,16 @@ same `Major.Minor.Bug` may be shared by functionally equivalent ports. If a released branch receives a bug fix that other branches do not require, only the affected branch's Bug number is incremented. For example, Forge -1.13.2 may move from `4.0.6.113021` to `4.0.7.113021` while unaffected branches -remain on their target-qualified 4.0.6 versions. - -If a different branch later receives a separate fix, it uses the next unused -Bug number, such as `4.0.8`, even if the `4.0.7` fix was not applicable to it. -A branch may therefore legitimately skip functional version numbers. +1.12.2 moved to `4.0.7.112021` for its packaged access-transformer repair while +unaffected branches remained on their target-qualified 4.0.6 versions. + +If a different branch later receives a shared fix, it uses the next unused +Bug number, such as Forge 1.14.4's `4.0.8.114041`, even though the 4.0.7 repair +was not applicable there. Forge 1.15.2 through 1.19.4 then advanced to their +target-qualified 4.0.9 releases for the provider terrain-host ordering repair. +Forge 1.18.2 and 1.19.4 then advanced to their target-qualified 4.0.10 releases +for the distinct Stable Layers actual-height eligibility repair. A branch may +therefore legitimately skip functional version numbers. This provides three useful guarantees: diff --git a/gradle.properties b/gradle.properties index cb3c967b..a4721e6d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,58 +2,31 @@ # 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 - -## Environment Properties - -# The Minecraft version must agree with the Forge version to get a valid artifact minecraft_version=1.19.4 -# The Minecraft version range can use any release version of Minecraft as bounds. -# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly -# as they do not follow standard versioning conventions. minecraft_version_range=[1.19.4,1.20) -# The Forge version must agree with the Minecraft version to get a valid artifact forge_version=45.4.0 -# The Forge version range can use any version of Forge as bounds or match the loader version range forge_version_range=[45,) -# The loader version range can only use the major version of Forge/FML as bounds loader_version_range=[45,) -# The mapping channel to use for mappings. -# The default set of supported mapping channels are ["official", "snapshot", "snapshot_nodoc", "stable", "stable_nodoc"]. -# Additional mapping channels can be registered through the "channelProviders" extension in a Gradle plugin. -# -# | Channel | Version | | -# |-----------|----------------------|--------------------------------------------------------------------------------| -# | official | MCVersion | Official field/method names from Mojang mapping files | -# | parchment | YYYY.MM.DD-MCVersion | Open community-sourced parameter names and javadocs layered on top of official | -# -# You must be aware of the Mojang license when using the 'official' or 'parchment' mappings. -# See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md -# -# Parchment is an unofficial project maintained by ParchmentMC, separate from Minecraft Forge. -# Additional setup is needed to use their mappings, see https://parchmentmc.org/docs/getting-started mapping_channel=official -# The mapping version to query from the mapping channel. -# This must match the format required by the mapping channel. mapping_version=1.19.4 +mcp_version=20230314.122934 - -## Mod Properties - -# The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63} -# Must match the String constant located in the main mod class annotated with @Mod. mod_id=orespawn -# The human-readable display name for the mod. mod_name=MMD OreSpawn -# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-2.1 -# The mod version. See https://semver.org/ -mod_version=4.0.6.119041 -# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. -# This should match the base package used for the mod sources. -# See https://maven.apache.org/guides/mini/guide-naming-conventions.html -mod_group_id=zone.moddev.mc.orespawn -# The authors of the mod. This is a simple text string that is used for display purposes in the mod list. +mod_version=4.0.10.119041 +mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team -# The description of the mod. This is a simple multiline text string that is used for display purposes in the mod list. mod_description=Configurable, provider-driven terrain, ore, and deposit generation. + +loader_name=forge +loader_code=1 +java_version=17 +java_toolchain_version=17.0.1+12 +gradle_java_version=17 +curseforge_project_id=245586 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index c1962a79..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 2617362f..2c68b418 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip -networkTimeout=10000 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 0077fa74..e7080f2e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,13 +1,5 @@ -pluginManagement { - repositories { - gradlePluginPortal() - maven { - name = 'MinecraftForge' - url = 'https://maven.minecraftforge.net/' - } - } -} - plugins { - id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' + id('org.gradle.toolchains.foojay-resolver-convention') version '1.0.0' } + +rootProject.name = 'OreSpawn' diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java index 8b52c03d..ae36963c 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -78,6 +78,15 @@ public final class SurfaceProbeTestMod { private static final ResourceLocation BIOME_B = new ResourceLocation(MODID + ":surface_b"); private static final ResourceLocation PROBE_GEOME = new ResourceLocation(MODID + ":dynamic_biome_geome"); private static final ResourceLocation DYNAMIC_FLUID = new ResourceLocation(MODID + ":fluid/dynamic_water"); + private static final Block[] NATURAL_SOURCES = { + Blocks.DIRT, Blocks.GRASS_BLOCK, Blocks.COARSE_DIRT, Blocks.PODZOL, + Blocks.ROOTED_DIRT, Blocks.GRAVEL, Blocks.SAND, Blocks.RED_SAND, + Blocks.CLAY, Blocks.TERRACOTTA, Blocks.WHITE_TERRACOTTA, + Blocks.ORANGE_TERRACOTTA, Blocks.RED_TERRACOTTA + }; + private static final Block[] INVALID_TERRAIN_HOSTS = { + Blocks.AIR, Blocks.WATER, Blocks.BEDROCK, Blocks.CHEST + }; private static final ResourceLocation[] BUILT_IN_GEOMES = { new ResourceLocation("orespawn:stable_craton"), new ResourceLocation("orespawn:mountain_belt"), new ResourceLocation("orespawn:volcanic_arc"), new ResourceLocation("orespawn:sedimentary_basin"), @@ -88,9 +97,11 @@ public final class SurfaceProbeTestMod { private static final int MAXIMUM_CHUNK = 65; private static final int EXPECTED_COLUMNS = 9 * 16 * 16; private static final int EXPECTED_FILLER = EXPECTED_COLUMNS * 3; + private static final int EXPECTED_NATURAL_SOURCES = 9 * NATURAL_SOURCES.length; private static final String PHASE_PROPERTY = "surfaceprobe.integrationPhase"; private static final String MARKER_NAME = "surfaceprobe-integration.properties"; private static final String CHEST_ITEM_NAME = "surfaceprobe sentinel"; + private static final String RAW_CHEST_ITEM_NAME = "surfaceprobe raw block entity sentinel"; static { FEATURES.register("terrain_setup", () -> new ProbeFeature(ProbeStage.TERRAIN)); @@ -173,6 +184,8 @@ private void enableGeologyProbe(ServerAboutToStartEvent event) { end.add("biome_namespaces", namespaces); JsonArray hosts = new JsonArray(); hosts.add(blockId(Blocks.END_STONE).toString()); + for (Block source : NATURAL_SOURCES) hosts.add(blockId(source).toString()); + for (Block source : INVALID_TERRAIN_HOSTS) hosts.add(blockId(source).toString()); end.add("host_blocks", hosts); end.add("host_tags", new JsonArray()); terrain.add(OPEN_ID.toString(), end); @@ -288,6 +301,13 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { int biomeB = 0; int edgeChanges = 0; int sentinels = 0; + long rawNaturalSources = 0L; + long structureNaturalSources = 0L; + long vegetationNaturalSources = 0L; + long cavePockets = 0L; + long underwaterPockets = 0L; + long rawBedrock = 0L; + long rawBlockEntities = 0L; BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { @@ -350,22 +370,94 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { if (previousChunkBiome != null && !previousChunkBiome.equals(centerBiome)) edgeChanges++; previousChunkBiome = centerBiome; sentinels += auditSentinels(level, chunk, pos, chunkMinX, chunkMinZ); + if (!roofed) { + NaturalSourceAudit natural = auditNaturalSources(level, chunk, pos, + chunkMinX, chunkMinZ); + rawNaturalSources += natural.rawConverted(); + structureNaturalSources += natural.structurePreserved(); + vegetationNaturalSources += natural.vegetationPreserved(); + cavePockets += natural.cavePreserved(); + underwaterPockets += natural.underwaterPreserved(); + rawBedrock += natural.bedrockPreserved(); + rawBlockEntities += natural.blockEntityPreserved(); + } } } if (top != EXPECTED_COLUMNS - 9 || underwater != 9 || filler != EXPECTED_FILLER || biomeA == 0 || biomeB == 0 || edgeChanges == 0 || sentinels != 9 * 4 || geology != (roofed ? 0 : EXPECTED_FILLER) - || (roofed && (ceiling != EXPECTED_COLUMNS || roofTop != EXPECTED_COLUMNS))) { + || (roofed && (ceiling != EXPECTED_COLUMNS || roofTop != EXPECTED_COLUMNS)) + || (!roofed && (rawNaturalSources != EXPECTED_NATURAL_SOURCES + || structureNaturalSources != EXPECTED_NATURAL_SOURCES + || vegetationNaturalSources != EXPECTED_NATURAL_SOURCES + || cavePockets != 54 || underwaterPockets != 63 + || rawBedrock != 9 || rawBlockEntities != 9))) { throw new IllegalStateException("Incomplete surface audit for " + level.dimension().location() + ": top=" + top + ", underwater=" + underwater + ", filler=" + filler + ", biomeA=" + biomeA + ", biomeB=" + biomeB + ", edges=" + edgeChanges + ", sentinels=" + sentinels + ", geology=" + geology - + ", ceiling=" + ceiling + ", roofTop=" + roofTop); + + ", ceiling=" + ceiling + ", roofTop=" + roofTop + + ", rawNatural=" + rawNaturalSources + + ", structureNatural=" + structureNaturalSources + + ", vegetationNatural=" + vegetationNaturalSources + + ", cavePockets=" + cavePockets + + ", underwaterPockets=" + underwaterPockets + + ", rawBedrock=" + rawBedrock + + ", rawBlockEntities=" + rawBlockEntities); } long aquiferFluid = roofed ? 0L : auditDynamicFluid(level); return new AuditResult(top, underwater, filler, geology, ceiling, roofTop, - biomeA, biomeB, edgeChanges, sentinels, aquiferFluid); + biomeA, biomeB, edgeChanges, sentinels, aquiferFluid, + rawNaturalSources, structureNaturalSources, vegetationNaturalSources, + cavePockets, underwaterPockets, rawBedrock, rawBlockEntities); + } + + private static NaturalSourceAudit auditNaturalSources(ServerLevel level, LevelChunk chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ) { + long rawConverted = 0L; + long structurePreserved = 0L; + long vegetationPreserved = 0L; + long cavePreserved = 0L; + long underwaterPreserved = 0L; + long bedrockPreserved = 0L; + long blockEntityPreserved = 0L; + for (int index = 0; index < NATURAL_SOURCES.length; index++) { + int x = naturalX(minX, index); + int z = naturalZ(minZ, index); + int groundY = findMarkedGround(chunk, pos, x, z, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + if (chunk.getBlockState(pos.set(x, groundY - 12, z)).is(Blocks.CALCITE)) rawConverted++; + Block pocket = chunk.getBlockState(pos.set(x, groundY - 11, z)).getBlock(); + if (index < NATURAL_SOURCES.length / 2) { + if (pocket == Blocks.AIR) cavePreserved++; + } else if (pocket == Blocks.WATER) { + underwaterPreserved++; + } + if (chunk.getBlockState(pos.set(x, groundY - 16, z)).is(NATURAL_SOURCES[index])) { + structurePreserved++; + } + if (chunk.getBlockState(pos.set(x, groundY - 20, z)).is(NATURAL_SOURCES[index])) { + vegetationPreserved++; + } + } + int bedrockGroundY = findMarkedGround(chunk, pos, minX + 11, minZ + 12, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + if (chunk.getBlockState(pos.set(minX + 11, bedrockGroundY - 24, minZ + 12)).is(Blocks.BEDROCK)) { + bedrockPreserved++; + } + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + pos.set(minX + 12, chestGroundY - 24, minZ + 12); + if (chunk.getBlockState(pos).is(Blocks.CHEST) + && level.getBlockEntity(pos) instanceof ChestBlockEntity chest + && chest.getItem(0).is(Items.EMERALD) + && RAW_CHEST_ITEM_NAME.equals(chest.getItem(0).getHoverName().getString())) { + blockEntityPreserved++; + } + return new NaturalSourceAudit(rawConverted, structurePreserved, + vegetationPreserved, cavePreserved, underwaterPreserved, + bedrockPreserved, blockEntityPreserved); } private static long auditDynamicFluid(ServerLevel level) { @@ -502,6 +594,13 @@ private static Properties properties(long seed, Map results values.setProperty(prefix + "edge_changes", Integer.toString(result.edgeChanges())); values.setProperty(prefix + "sentinels", Integer.toString(result.sentinels())); values.setProperty(prefix + "aquifer_fluid", Long.toString(result.aquiferFluid())); + values.setProperty(prefix + "raw_natural_sources", Long.toString(result.rawNaturalSources())); + values.setProperty(prefix + "structure_natural_sources", Long.toString(result.structureNaturalSources())); + values.setProperty(prefix + "vegetation_natural_sources", Long.toString(result.vegetationNaturalSources())); + values.setProperty(prefix + "cave_pockets", Long.toString(result.cavePockets())); + values.setProperty(prefix + "underwater_pockets", Long.toString(result.underwaterPockets())); + values.setProperty(prefix + "raw_bedrock", Long.toString(result.rawBedrock())); + values.setProperty(prefix + "raw_block_entities", Long.toString(result.rawBlockEntities())); } return values; } @@ -596,9 +695,39 @@ private static boolean prepareTerrain(WorldGenLevel world, ChunkAccess chunk) { } } } + if (!roofed) placeRawNaturalSources(world, chunk, pos, minX, minZ); return true; } + private static void placeRawNaturalSources(WorldGenLevel world, ChunkAccess chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ) { + for (int index = 0; index < NATURAL_SOURCES.length; index++) { + int x = naturalX(minX, index); + int z = naturalZ(minZ, index); + int groundY = findMarkedGround(chunk, pos, x, z, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + chunk.setBlockState(pos.set(x, groundY - 12, z), + NATURAL_SOURCES[index].defaultBlockState(), false); + chunk.setBlockState(pos.set(x, groundY - 11, z), + (index < NATURAL_SOURCES.length / 2 ? Blocks.AIR : Blocks.WATER) + .defaultBlockState(), false); + } + int bedrockGroundY = findMarkedGround(chunk, pos, minX + 11, minZ + 12, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + chunk.setBlockState(pos.set(minX + 11, bedrockGroundY - 24, minZ + 12), + Blocks.BEDROCK.defaultBlockState(), false); + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + world.setBlock(pos.set(minX + 12, chestGroundY - 24, minZ + 12), + Blocks.CHEST.defaultBlockState(), 2); + if (world.getBlockEntity(pos) instanceof ChestBlockEntity chest) { + ItemStack sentinel = new ItemStack(Items.EMERALD); + sentinel.setHoverName(Component.literal(RAW_CHEST_ITEM_NAME)); + chest.setItem(0, sentinel); + chest.setChanged(); + } + } + private static boolean solid(BlockState state) { return !state.isAir() && state.getFluidState().isEmpty(); } @@ -618,6 +747,7 @@ private static boolean placeStructureSentinels(WorldGenLevel world, ChunkAccess chest.setItem(0, sentinel); chest.setChanged(); } + placeAuthoredNaturalSources(world, chunk, pos, minX, minZ, 16); return true; } @@ -633,9 +763,31 @@ private static boolean placeVegetationSentinels(WorldGenLevel world, ChunkAccess int vegetationY = markedGround(chunk, pos, minX + 6, minZ + 6, world); world.setBlock(pos.set(minX + 6, vegetationY + 1, minZ + 6), Blocks.DIRT.defaultBlockState(), 2); world.setBlock(pos.set(minX + 6, vegetationY + 2, minZ + 6), Blocks.OAK_SAPLING.defaultBlockState(), 2); + placeAuthoredNaturalSources(world, chunk, pos, minX, minZ, 20); return true; } + private static void placeAuthoredNaturalSources(WorldGenLevel world, ChunkAccess chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ, int depth) { + if (!world.getLevel().dimension().equals(OPEN)) return; + for (int index = 0; index < NATURAL_SOURCES.length; index++) { + int x = naturalX(minX, index); + int z = naturalZ(minZ, index); + int groundY = findMarkedGround(chunk, pos, x, z, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + world.setBlock(pos.set(x, groundY - depth, z), + NATURAL_SOURCES[index].defaultBlockState(), 2); + } + } + + private static int naturalX(int minX, int index) { + return minX + 12 + index % 4; + } + + private static int naturalZ(int minZ, int index) { + return minZ + 1 + index / 4; + } + private static int markedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, int x, int z, WorldGenLevel world) { return findMarkedGround(chunk, pos, x, z, world.getMinBuildHeight(), world.getMaxBuildHeight()); @@ -644,7 +796,14 @@ private static int markedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, private record Material(BlockState top, BlockState filler, BlockState underwater, BlockState ceiling) { } + private record NaturalSourceAudit(long rawConverted, long structurePreserved, + long vegetationPreserved, long cavePreserved, long underwaterPreserved, + long bedrockPreserved, long blockEntityPreserved) { } + private record AuditResult(long top, long underwater, long filler, long geology, long ceiling, long roofTop, int biomeA, int biomeB, - int edgeChanges, int sentinels, long aquiferFluid) { } + int edgeChanges, int sentinels, long aquiferFluid, + long rawNaturalSources, long structureNaturalSources, + long vegetationNaturalSources, long cavePockets, long underwaterPockets, + long rawBedrock, long rawBlockEntities) { } } diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java new file mode 100644 index 00000000..03e0c8f3 --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java @@ -0,0 +1,525 @@ +package zone.moddev.mc.orespawn.clientprobe; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +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.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.TabButton; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.components.tabs.TabNavigationBar; +import net.minecraft.client.gui.components.tabs.TabManager; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.worldselection.ConfirmExperimentalFeaturesScreen; +import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; +import net.minecraftforge.client.event.RenderLevelLastEvent; +import net.minecraftforge.client.event.ScreenEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.fml.util.ObfuscationReflectionHelper; +import zone.moddev.mc.orespawn.client.OreSpawnWorldSettingsScreen; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Build-only isolated client probe. It is compiled and packaged outside every release artifact. */ +@Mod(ClientProbeTestMod.MODID) +@Mod.EventBusSubscriber(modid = ClientProbeTestMod.MODID, value = Dist.CLIENT) +public final class ClientProbeTestMod { + static final String MODID = "clientprobe"; + private static final String WORLD_DIRECTORY = "New World"; + private static final String WORLD_SEED = "-4965128775892001975"; + private static volatile ClientProbeTestMod instance; + private final Set editorRoutes = new HashSet<>(); + private final Set attemptedButtons = new HashSet<>(); + private int state; + private int stateTicks; + private int firstWorldFrames; + private int reloadWorldFrames; + private int editorFrames; + private boolean worldSettingsOpened; + private boolean longEditorRoundTrip; + private List worldCreationButtons; + + public ClientProbeTestMod() { + instance = this; + } + + @SubscribeEvent + public static void onScreenInitialized(ScreenEvent.Init.Post event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (!(event.getScreen() instanceof CreateWorldScreen)) return; + probe.worldCreationButtons = event.getListenersList(); + } + + @SubscribeEvent + public static void onScreenDrawn(ScreenEvent.Render.Post event) { + ClientProbeTestMod probe = instance; + if (probe != null && Boolean.getBoolean("clientprobe.enabled") + && isOreSpawnEditor(event.getScreen())) probe.editorFrames++; + } + + @SubscribeEvent + public static void onWorldRendered(RenderLevelLastEvent event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (probe.state == 6) probe.firstWorldFrames++; + if (probe.state == 8) probe.reloadWorldFrames++; + } + + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + ClientProbeTestMod probe = instance; + if (probe == null || event.phase != TickEvent.Phase.END + || !Boolean.getBoolean("clientprobe.enabled")) return; + probe.handleClientTick(); + } + + private void handleClientTick() { + Minecraft minecraft = Minecraft.getInstance(); + if (++stateTicks > 3600) fail(minecraft, "Timed out in client probe state " + state); + try { + switch (state) { + case 0: + if (minecraft.screen instanceof TitleScreen) { + CreateWorldScreen.openFresh(minecraft, minecraft.screen); + nextState(1); + } + break; + case 1: + if (minecraft.screen instanceof CreateWorldScreen + && activateWorldSettings(minecraft.screen, worldCreationButtons)) { + worldSettingsOpened = true; + nextState(2); + } + break; + case 2: + if (minecraft.screen instanceof CreateWorldScreen && stateTicks >= 2) { + validateCaptions(minecraft.screen); + validateLongEditorRoundTrip(minecraft, minecraft.screen); + nextState(3); + } + break; + case 3: + if (minecraft.screen instanceof CreateWorldScreen) { + CreateWorldScreen root = (CreateWorldScreen) minecraft.screen; + Button target = nextNavigationButton(root); + if (target == null) { + if (editorRoutes.size() < 5) fail(minecraft, + "Only exercised " + editorRoutes.size() + " editor routes: " + editorRoutes); + root.getUiState().setSeed(WORLD_SEED); + pressCreateWorld(root); + nextState(6); + } else { + Screen before = minecraft.screen; + target.onPress(); + if (minecraft.screen != before && isOreSpawnEditor(minecraft.screen)) { + editorRoutes.add(minecraft.screen.getClass().getSimpleName()); + editorFrames = 0; + nextState(4); + } + } + } + break; + case 4: + if (isOreSpawnEditor(minecraft.screen) && editorFrames >= 2) { + validateCaptions(minecraft.screen); + minecraft.screen.onClose(); + nextState(3); + } + break; + case 5: + break; + case 6: + if (minecraft.screen instanceof ConfirmScreen) { + pressWorldCreationConfirmation((ConfirmScreen) minecraft.screen); + } + if (minecraft.screen instanceof ConfirmExperimentalFeaturesScreen) { + pressExperimentalProceed((ConfirmExperimentalFeaturesScreen) minecraft.screen); + } + if (minecraft.level != null && minecraft.player != null && firstWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(7); + } + break; + case 7: + if (minecraft.level == null && !minecraft.hasSingleplayerServer() && stateTicks >= 20) { + minecraft.createWorldOpenFlows().loadLevel(new TitleScreen(), WORLD_DIRECTORY); + nextState(8); + } + break; + case 8: + if (minecraft.screen instanceof ConfirmScreen) { + pressWorldCreationConfirmation((ConfirmScreen) minecraft.screen); + } + if (minecraft.screen instanceof ConfirmExperimentalFeaturesScreen) { + pressExperimentalProceed((ConfirmExperimentalFeaturesScreen) minecraft.screen); + } + if (minecraft.level != null && minecraft.player != null && reloadWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(9); + } + break; + case 9: + if (minecraft.level == null && !minecraft.hasSingleplayerServer()) { + writeMarker(); + minecraft.stop(); + nextState(10); + } + break; + default: + break; + } + } catch (RuntimeException | IOException failure) { + fail(minecraft, failure.toString()); + } + } + + private Button nextNavigationButton(CreateWorldScreen root) { + for (AbstractWidget widget : widgets(root)) { + if (!(widget instanceof Button) || widget instanceof CycleButton + || !widget.visible || !widget.active) continue; + Button button = (Button) widget; + String caption = ChatFormatting.stripFormatting(button.getMessage().getString()); + if (!attemptedButtons.add(caption)) continue; + String lower = caption.toLowerCase(java.util.Locale.ROOT); + if (lower.equals("done") || lower.equals("cancel") || lower.equals("game") + || lower.equals("world") || lower.equals("more") || lower.equals("orespawn") + || lower.contains("create new world") || lower.contains("recommended")) continue; + return button; + } + return null; + } + + private static void validateCaptions(Screen screen) { + for (AbstractWidget widget : widgets(screen)) { + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + if (caption == null || caption.trim().isEmpty() + || caption.contains("options.generic_value") + || caption.startsWith("button.orespawn.") + || caption.startsWith("option.orespawn.")) { + throw new IllegalStateException("Invalid client caption: " + widget.getMessage()); + } + } + } + + private void validateLongEditorRoundTrip(Minecraft minecraft, Screen 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()); + + Object session = newEditorSession(root); + String before = editorSessionRoot(session); + + Screen oreScreen = newDimensionScreen("OreDimensionScreen", parent, session, + "example:long_editor_ore", "minecraft:overworld"); + initializeScreen(oreScreen, minecraft); + pressDone(oreScreen); + + Screen fluidScreen = newDimensionScreen("FluidDepositDimensionScreen", parent, session, + "example:long_editor_deposit", "minecraft:overworld"); + initializeScreen(fluidScreen, minecraft); + pressDone(fluidScreen); + + String after = editorSessionRoot(session); + if (!before.equals(after)) { + throw new IllegalStateException("Opening and saving long editor values changed profile JSON\nBefore: " + + before + "\nAfter: " + after); + } + longEditorRoundTrip = true; + } + + private static Object newEditorSession(JsonObject root) { + try { + Class sessionClass = Class.forName( + "zone.moddev.mc.orespawn.client.GeologyEditorSession"); + java.lang.reflect.Constructor constructor = sessionClass.getDeclaredConstructor( + WorldGeologyProfile.class); + constructor.setAccessible(true); + return constructor.newInstance(WorldGeologyProfile.recommended(true).withRoot(root)); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not create the target-native editor session", failure); + } + } + + private static Screen newDimensionScreen(String simpleName, Screen parent, Object session, + String ruleId, String dimensionId) { + try { + Class sessionClass = session.getClass(); + Class screenClass = Class.forName( + "zone.moddev.mc.orespawn.client." + simpleName); + java.lang.reflect.Constructor constructor = screenClass.getDeclaredConstructor( + Screen.class, sessionClass, String.class, String.class); + constructor.setAccessible(true); + return (Screen) constructor.newInstance(parent, session, ruleId, dimensionId); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not create target-native editor " + simpleName, failure); + } + } + + private static String editorSessionRoot(Object session) { + try { + Method root = session.getClass().getDeclaredMethod("root"); + root.setAccessible(true); + return root.invoke(session).toString(); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not read the target-native editor session", failure); + } + } + + private static JsonArray values(String... entries) { + JsonArray result = new JsonArray(); + for (String entry : entries) result.add(new JsonPrimitive(entry)); + return result; + } + + private static void initializeScreen(Screen screen, Minecraft minecraft) { + for (Method method : Screen.class.getDeclaredMethods()) { + Class[] parameters = method.getParameterTypes(); + if (parameters.length != 3 || parameters[0] != Minecraft.class + || parameters[1] != int.class || parameters[2] != int.class + || method.getReturnType() != void.class) continue; + try { + method.setAccessible(true); + method.invoke(screen, minecraft, 640, 480); + return; + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not initialize target-native editor", failure); + } + } + throw new IllegalStateException("Could not locate Forge 45 Screen initialization method"); + } + + private static void pressDone(Screen screen) { + for (AbstractWidget widget : widgets(screen)) { + if (!(widget instanceof Button)) continue; + String caption = ChatFormatting.stripFormatting(((Button) widget).getMessage().getString()); + if ("done".equalsIgnoreCase(caption)) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException("Editor did not expose its Done action: " + + screen.getClass().getSimpleName()); + } + + private static boolean isOreSpawnEditor(Screen screen) { + return screen != null && screen.getClass().getName().startsWith( + "zone.moddev.mc.orespawn.client."); + } + + private static boolean isWorldSettingsControl(AbstractWidget widget) { + return (widget instanceof Button || widget instanceof TabButton) + && ChatFormatting.stripFormatting(widget.getMessage().getString()) + .toLowerCase(java.util.Locale.ROOT).contains("orespawn"); + } + + private static boolean activateWorldSettings(GuiEventListener listener) { + if (listener instanceof TabNavigationBar) { + TabNavigationBar navigation = (TabNavigationBar) listener; + List children = navigation.children(); + for (int index = 0; index < children.size(); index++) { + GuiEventListener child = children.get(index); + if (child instanceof AbstractWidget && isWorldSettingsControl((AbstractWidget) child)) { + navigation.selectTab(index, true); + return true; + } + } + } + if (listener instanceof Button && isWorldSettingsControl((AbstractWidget) listener)) { + ((Button) listener).onPress(); + return true; + } + return false; + } + + private static boolean activateWorldSettings(Screen screen, List initializedListeners) { + for (GuiEventListener child : screen.children()) { + if (activateWorldSettings(child)) return true; + } + if (initializedListeners != null) { + for (GuiEventListener child : initializedListeners) { + if (activateWorldSettings(child)) return true; + } + } + return false; + } + + private static void pressCreateWorld(CreateWorldScreen screen) { + for (AbstractWidget widget : widgets(screen)) { + if (!(widget instanceof Button)) continue; + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + if (caption != null && caption.toLowerCase(java.util.Locale.ROOT).contains("create new world")) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException("Create World screen did not expose its Create New World action"); + } + + private static void pressExperimentalProceed(ConfirmExperimentalFeaturesScreen screen) { + for (AbstractWidget widget : widgets(screen)) { + if (!(widget instanceof Button) || !widget.visible || !widget.active) continue; + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + if (caption != null && caption.equalsIgnoreCase("Proceed")) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException("Experimental world confirmation did not expose its Proceed action"); + } + + private static void pressWorldCreationConfirmation(ConfirmScreen screen) { + for (AbstractWidget widget : widgets(screen)) { + if (!(widget instanceof Button) || !widget.visible || !widget.active) continue; + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + String lower = caption == null ? "" : caption.toLowerCase(java.util.Locale.ROOT); + if (!lower.equals("no") && !lower.equals("cancel") && !lower.equals("back")) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException("World creation confirmation did not expose an affirmative action"); + } + + private static java.util.List widgets(Screen screen) { + java.util.List result = new java.util.ArrayList<>(); + for (GuiEventListener child : screen.children()) { + if (child instanceof AbstractWidget) result.add((AbstractWidget) child); + } + if (screen instanceof CreateWorldScreen) { + TabManager manager = ObfuscationReflectionHelper.getPrivateValue( + CreateWorldScreen.class, (CreateWorldScreen) screen, "f_267424_"); + if (manager != null && manager.getCurrentTab() != null) { + manager.getCurrentTab().visitChildren(widget -> { + if (!result.contains(widget)) result.add(widget); + }); + } + } + return result; + } + + private static void stopIntegratedServer(Minecraft minecraft) { + // Match Forge 45's target-native disconnect path. clearLevel(Screen) clears + // the integrated-server state as well as the client world; loadWorld(null) only + // swaps the client world on this target and would leave reload stuck. + if (minecraft.level != null) minecraft.level.disconnect(); + minecraft.clearLevel(new TitleScreen()); + } + + 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.19.4 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.stop(); + throw new IllegalStateException(message); + } +} diff --git a/src/clientIntegrationTest/resources/META-INF/mods.toml b/src/clientIntegrationTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..915c6f9a --- /dev/null +++ b/src/clientIntegrationTest/resources/META-INF/mods.toml @@ -0,0 +1,30 @@ +modLoader="javafml" +loaderVersion="[45,)" +license="LGPL-2.1" + +[[mods]] +modId="clientprobe" +version="1" +displayName="OreSpawn Client Probe" +description='''Build-only OreSpawn client editor and world reload fixture.''' + +[[dependencies.clientprobe]] +modId="forge" +mandatory=true +versionRange="[45,)" +ordering="NONE" +side="CLIENT" + +[[dependencies.clientprobe]] +modId="orespawn" +mandatory=true +versionRange="[4.0.6,5.0.0)" +ordering="AFTER" +side="CLIENT" + +[[dependencies.clientprobe]] +modId="minecraft" +mandatory=true +versionRange="[1.19.4,1.20)" +ordering="NONE" +side="CLIENT" diff --git a/src/clientIntegrationTest/resources/pack.mcmeta b/src/clientIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..2f908050 --- /dev/null +++ b/src/clientIntegrationTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "OreSpawn client qualification fixture", + "forge:resource_pack_format": 13, + "forge:data_pack_format": 12, + "pack_format": 13 + } +} 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 957fe75d..64283cac 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java @@ -156,7 +156,7 @@ private EditBox placementField(int index, String key, String value) { int fieldWidth = Math.min(72, Math.max(58, columnWidth / 3)); EditBox box = new EditBox(font, groupX + columnWidth - fieldWidth, 90 + (row * 24), fieldWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(32); + box.setMaxLength(32); box.setValue(value); OreSpawnScreenLayout.explain(box, placementHelp(key)); placementWidgets.add(addRenderableWidget(box)); return box; @@ -165,7 +165,7 @@ private EditBox placementField(int index, String key, String value) { private EditBox hostField(int index, String key, String value) { int x = index == 0 ? left : left + columnWidth + 5; EditBox box = new EditBox(font, x, 106, columnWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); OreSpawnScreenLayout.explain(box, "tooltip.orespawn." + key); hostWidgets.add(addRenderableWidget(box)); return box; @@ -175,7 +175,7 @@ private EditBox biomeField(int index, String key, String value) { int x = (index & 1) == 0 ? left : left + columnWidth + 5; int y = 106 + ((index / 2) * 44); EditBox box = new EditBox(font, x, y, columnWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); OreSpawnScreenLayout.explain(box, "tooltip.orespawn.fluid." + key); biomeWidgets.add(addRenderableWidget(box)); 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 b3e3b58c..a368959c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java @@ -238,8 +238,8 @@ protected void init() { private EditBox addPlacementField(int x, int y, String key, String value) { EditBox box = new EditBox(font, x, y, columnWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(box, placementHelp(key)); placementWidgets.add(addRenderableWidget(box)); return box; @@ -251,8 +251,8 @@ private int compactPlacementFieldY(int row) { private EditBox addHostField(int x, int y, String key, String value) { EditBox box = new EditBox(font, x, y, contentWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(1024); + box.setValue(value); OreSpawnScreenLayout.explain(box, "tooltip.orespawn." + key); hostWidgets.add(addRenderableWidget(box)); return box; @@ -260,8 +260,8 @@ private EditBox addHostField(int x, int y, String key, String value) { private EditBox addPatternField(int x, int y, String key, String value) { EditBox box = new EditBox(font, x, y, columnWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(box, "tooltip.orespawn.ore." + key); patternWidgets.add(addRenderableWidget(box)); return box; diff --git a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java index b8192939..54a9de16 100644 --- a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java +++ b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java @@ -30,6 +30,7 @@ public final class DocumentationExporter { "MIGRATION.md", "TROUBLESHOOTING.md", "AGENTS.md", + "VERSIONS.md", "examples/examplemod-orespawn.json", "examples/orespawn-global.json", "examples/orespawn-world.json", diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java index 2ab8ec77..a8790288 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java @@ -35,6 +35,7 @@ public final class BakedGeomeConfig { private final Map biomeWeights; private final Map biomeWeightsById; private final double[] fallbackWeights; + private final RockEntry[] rocks; private final BlockState[] rockStates; private final Set sedimentaryBlocks; private final Set oreReplaceableBlocks; @@ -45,6 +46,9 @@ public final class BakedGeomeConfig { private WeightedBlockPicker[][][] legacyRockPickers; private byte[] stableFamilyChoices; private int[] stableRockChoices; + private int[][] familyRockIndexes; + private double[][][] stableRockLogWeights; + private double[][][] stableRockPriorities; BakedGeomeConfig(GeomeDefinition[] geomes, double geomeScale, double biomeInfluence, double regionalNoiseInfluence, double boundaryNoiseInfluence, Map biomeWeights, @@ -72,6 +76,7 @@ public final class BakedGeomeConfig { noiseOffsetZ[i] = -((i + 1) * 6151); } + this.rocks = rocks.clone(); rockStates = new BlockState[rocks.length]; for (int i = 0; i < rocks.length; i++) { rockStates[i] = rocks[i].state; @@ -152,15 +157,69 @@ RockFamily pickFamily(int geomeIndex, int y, int formationValue, int diversitySl return RockFamily.SEDIMENTARY; } + RockFamily pickStableFamilyAtWorldY(int geomeIndex, int worldY, int formationY, + int formationValue, int diversitySlot) { + RockFamily preferred = pickFamily(geomeIndex, formationY, formationValue, diversitySlot); + if (hasEligibleStableRock(geomeIndex, preferred, worldY, formationY)) { + return preferred; + } + + int bucket = formationValue & 0xFF; + int boundedFormationY = clampStableValue(formationY); + double bestScore = Double.NEGATIVE_INFINITY; + RockFamily bestFamily = preferred; + for (RockFamily family : RockFamily.values()) { + if (!hasEligibleStableRock(geomeIndex, family, worldY, formationY)) { + continue; + } + double weight = Math.pow(geomes[geomeIndex].familyWeights[family.ordinal()], 2.5D) + * familyDepthWeight(family, boundedFormationY); + if (weight <= 0.0D) { + continue; + } + double score = Math.log(weight) + gumbelPriority(bucket, geomeIndex, family.ordinal(), + isStableBucket(bucket, -1), 0x6A09E667F3BCC909L); + if (score > bestScore) { + bestScore = score; + bestFamily = family; + } + } + return bestFamily; + } + public BlockState pickRock(int geomeIndex, RockFamily family, int y, int formationValue) { if (formations.usesStableLayers()) { - int index = stableRockIndex(geomeIndex, family.ordinal(), clampStableY(y), formationValue & 0xFF); - int rockIndex = stableRockChoices[index]; - return rockIndex < 0 ? FALLBACK : rockStates[rockIndex]; + return pickStableRockAtWorldY(geomeIndex, family, y, y, formationValue); } return legacyRockPickers[geomeIndex][family.ordinal()][clampLegacyY(y)].pick(formationValue); } + BlockState pickStableRockAtWorldY(int geomeIndex, RockFamily family, int worldY, + int formationY, int formationValue) { + int yIndex = clampStableY(formationY); + int bucket = formationValue & 0xFF; + int choiceIndex = stableRockIndex(geomeIndex, family.ordinal(), yIndex, bucket); + int selectedRock = stableRockChoices[choiceIndex]; + if (isEligibleStableRock(geomeIndex, selectedRock, worldY, yIndex)) { + return rockStates[selectedRock]; + } + + double bestScore = Double.NEGATIVE_INFINITY; + int bestRock = -1; + for (int rockIndex : familyRockIndexes[family.ordinal()]) { + if (!isEligibleStableRock(geomeIndex, rockIndex, worldY, yIndex)) { + continue; + } + double score = stableRockLogWeights[geomeIndex][rockIndex][yIndex] + + stableRockPriorities[geomeIndex][rockIndex][bucket]; + if (score > bestScore) { + bestScore = score; + bestRock = rockIndex; + } + } + return bestRock < 0 ? FALLBACK : rockStates[bestRock]; + } + public String geomeName(int geomeIndex) { return geomes[geomeIndex].name; } @@ -283,9 +342,9 @@ private void buildStablePickers(RockEntry[] rocks) { stableRockChoices = new int[geomes.length * RockFamily.values().length * HEIGHT * FORMATION_BUCKETS]; Arrays.fill(stableRockChoices, -1); int familyCount = RockFamily.values().length; - int[][] familyRockIndexes = groupRockIndexes(rocks); - double[][][] rockLogWeights = new double[geomes.length][rocks.length][HEIGHT]; - double[][][] rockPriorities = new double[geomes.length][rocks.length][FORMATION_BUCKETS]; + familyRockIndexes = groupRockIndexes(rocks); + stableRockLogWeights = new double[geomes.length][rocks.length][HEIGHT]; + stableRockPriorities = new double[geomes.length][rocks.length][FORMATION_BUCKETS]; double[][][] familyLogWeights = new double[geomes.length][familyCount][HEIGHT]; double[][][] familyWeights = new double[geomes.length][familyCount][HEIGHT]; double[][][] familyPriorities = new double[geomes.length][familyCount][FORMATION_BUCKETS]; @@ -294,14 +353,13 @@ private void buildStablePickers(RockEntry[] rocks) { for (int rockIndex = 0; rockIndex < rocks.length; rockIndex++) { RockEntry rock = rocks[rockIndex]; for (int y = MIN_Y; y <= MAX_Y; y++) { - double rawWeight = y < rock.minY || y > rock.maxY ? 0.0D - : rock.weight * rock.geomeWeights[geome] - * depthWeight(y, rock.depthPeak, rock.depthSpread); - rockLogWeights[geome][rockIndex][y - MIN_Y] = rawWeight > 0.0D + double rawWeight = rock.weight * rock.geomeWeights[geome] + * depthWeight(y, rock.depthPeak, rock.depthSpread); + stableRockLogWeights[geome][rockIndex][y - MIN_Y] = rawWeight > 0.0D ? Math.log(rawWeight) : Double.NEGATIVE_INFINITY; } for (int bucket = 0; bucket < FORMATION_BUCKETS; bucket++) { - rockPriorities[geome][rockIndex][bucket] = gumbelPriority(bucket, geome, rockIndex, + stableRockPriorities[geome][rockIndex][bucket] = gumbelPriority(bucket, geome, rockIndex, isStableBucket(bucket, rock.family.ordinal()), 0xBB67AE8584CAA73BL ^ ((long) rock.family.ordinal() << 32)); } @@ -313,7 +371,9 @@ private void buildStablePickers(RockEntry[] rocks) { int yIndex = y - MIN_Y; boolean available = false; for (int rockIndex : familyRockIndexes[familyIndex]) { - if (rockLogWeights[geome][rockIndex][yIndex] != Double.NEGATIVE_INFINITY) { + RockEntry rock = rocks[rockIndex]; + if (y >= rock.minY && y <= rock.maxY + && stableRockLogWeights[geome][rockIndex][yIndex] != Double.NEGATIVE_INFINITY) { available = true; break; } @@ -363,8 +423,12 @@ private void buildStablePickers(RockEntry[] rocks) { double bestRockScore = Double.NEGATIVE_INFINITY; int bestRock = -1; for (int rockIndex : familyRockIndexes[familyIndex]) { - double rockScore = rockLogWeights[geome][rockIndex][yIndex] - + rockPriorities[geome][rockIndex][bucket]; + RockEntry rock = rocks[rockIndex]; + if (y < rock.minY || y > rock.maxY) { + continue; + } + double rockScore = stableRockLogWeights[geome][rockIndex][yIndex] + + stableRockPriorities[geome][rockIndex][bucket]; if (rockScore > bestRockScore) { bestRockScore = rockScore; bestRock = rockIndex; @@ -377,6 +441,25 @@ private void buildStablePickers(RockEntry[] rocks) { } } + private boolean hasEligibleStableRock(int geomeIndex, RockFamily family, int worldY, int formationY) { + int yIndex = clampStableY(formationY); + for (int rockIndex : familyRockIndexes[family.ordinal()]) { + if (isEligibleStableRock(geomeIndex, rockIndex, worldY, yIndex)) { + return true; + } + } + return false; + } + + private boolean isEligibleStableRock(int geomeIndex, int rockIndex, int worldY, int formationYIndex) { + if (rockIndex < 0) { + return false; + } + RockEntry rock = rocks[rockIndex]; + return worldY >= rock.minY && worldY <= rock.maxY + && stableRockLogWeights[geomeIndex][rockIndex][formationYIndex] != Double.NEGATIVE_INFINITY; + } + private void fillBalancedFamilyCycle(int geome, int yIndex, int bucket, double[][][] familyWeights, double[][][] familyPriorities, int[] quotas, int[] remaining, double[] remainders, boolean[] bonusAwarded) { @@ -631,6 +714,10 @@ private static int clampStableY(int y) { return Math.max(MIN_Y, Math.min(MAX_Y, y)) - MIN_Y; } + private static int clampStableValue(int y) { + return Math.max(MIN_Y, Math.min(MAX_Y, y)); + } + private static int clampLegacyY(int y) { return Math.max(0, Math.min(LEGACY_MAX_Y, y)); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java index eabe8b11..2f0cea9e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java @@ -8,6 +8,8 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.block.state.BlockState; /** Immutable setup-time resolution of one terrain replacement dimension. */ @@ -38,6 +40,11 @@ boolean hasBiomeFilter() { } boolean isReplaceable(BlockState state) { + if (state.isAir() || state.getBlock() instanceof LiquidBlock + || !state.getFluidState().isEmpty() + || state.getBlock() == Blocks.BEDROCK) { + return false; + } if (smallHostSet != null) { Block block = state.getBlock(); for (Block host : smallHostSet) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java index 20bba705..51c8cd86 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java @@ -112,8 +112,9 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer for (; y >= chunk.getMinBuildHeight(); y--) { cursor.set(x, y, z); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current) - || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) { + if ((terrain.isReplaceable(current) + || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) + && chunk.getBlockEntity(cursor) == null) { BlockState replacement = pickReplacement(baseRockVal, geomeBase, y); if (current.equals(replacement)) continue; chunk.setBlockState(cursor, replacement, false); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java index 9f6ad959..352fcd85 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java @@ -119,7 +119,8 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer } else { for (int y = surfaceY; y >= chunk.getMinBuildHeight(); y--) { cursor.set(x, y, z); - if (terrain.isReplaceable(chunk.getBlockState(cursor))) { + if (terrain.isReplaceable(chunk.getBlockState(cursor)) + && chunk.getBlockEntity(cursor) == null) { chunk.setBlockState(cursor, pickReplacement(geomeIndex, baseRockValue, formationRegion, x, y, z), false); changed = true; @@ -142,7 +143,6 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos int layerStart = layerIndex * layerThickness; int layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, layerIndex, geomeTransitionPhase); - BlockState replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); boolean changed = false; cursor.set(x, surfaceY, z); @@ -153,11 +153,12 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos layerStart -= layerThickness; layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, layerIndex, geomeTransitionPhase); - replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); } cursor.setY(y); - if (terrain.isReplaceable(chunk.getBlockState(cursor))) { - chunk.setBlockState(cursor, replacement, false); + if (terrain.isReplaceable(chunk.getBlockState(cursor)) + && chunk.getBlockEntity(cursor) == null) { + chunk.setBlockState(cursor, + pickStableReplacement(layerGeome, formationRegion, layerIndex, y), false); changed = true; } } @@ -250,7 +251,7 @@ private net.minecraft.world.level.block.state.BlockState pickReplacement(int geo int stratum = baseRockValue + y; int layerIndex = Math.floorDiv(stratum, layerThickness); if (stableLayers) { - return pickStableReplacement(geomeIndex, formationRegion, layerIndex); + return pickStableReplacement(geomeIndex, formationRegion, layerIndex, y); } int layerY = y + (layerThickness / 2) - Math.floorMod(stratum, layerThickness); @@ -260,7 +261,7 @@ private net.minecraft.world.level.block.state.BlockState pickReplacement(int geo return config.pickRock(geomeIndex, family, layerY, rockHash); } - private BlockState pickStableReplacement(int geomeIndex, long formationRegion, int layerIndex) { + private BlockState pickStableReplacement(int geomeIndex, long formationRegion, int layerIndex, int worldY) { // A dipping or uplifted layer keeps the depth identity it had in stratum space. int formationY = (layerIndex * layerThickness) + (layerThickness / 2); int layerBucket = layerIndex & 0xFF; @@ -286,8 +287,9 @@ private BlockState pickStableReplacement(int geomeIndex, long formationRegion, i // from collapsing onto one exact rock. rockBucket ^= LITHOLOGY_ROCK_SALTS[familySlot]; } - RockFamily family = config.pickFamily(geomeIndex, formationY, familyBucket, familySlot); - return config.pickRock(geomeIndex, family, formationY, rockBucket); + RockFamily family = config.pickStableFamilyAtWorldY(geomeIndex, worldY, formationY, + familyBucket, familySlot); + return config.pickStableRockAtWorldY(geomeIndex, family, worldY, formationY, rockBucket); } int stratumOffsetAt(int x, int z) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java index d2ea997a..01565bf7 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -212,7 +212,7 @@ private static JsonObject convertSpawn(String name, JsonObject source, List 0) ore.add("dimensions", dimensions); @@ -358,7 +358,7 @@ private static void writeReport(Path config, List lines) { private static void writeUpgradeReport(Path config, int imported, List detail) { List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.6.119041 Upgrade Report"); + lines.add("OreSpawn 4.0.10.119041 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Legacy OreSpawn settings were imported into the OS4 profile."); 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 705ec8db..99fa5153 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -204,7 +204,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configPath, Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); List missing = missingBlocks(igneous, metamorphic, sedimentary); List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.6.119041 Upgrade Report"); + lines.add("OreSpawn 4.0.10.119041 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/OreSpawnBiomeModifier.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java index b4dd849f..9be69900 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java @@ -41,7 +41,6 @@ static boolean apply(BiomeGenerationSettingsBuilder generation) { generation.getFeatures(GenerationStep.Decoration.UNDERGROUND_ORES); changed |= StoneReplacer.wrapVanillaMatchingStoneFeatures(underground); changed |= VanillaOreFeatureGate.wrapFeatureList(underground); - changed |= addUnique(underground, StoneReplacer.placedFeature()); changed |= addUnique(underground, OreSpawnOreGeneration.placedFeature()); changed |= addUnique(underground, FluidDepositFeature.placedFeature()); @@ -51,7 +50,8 @@ static boolean apply(BiomeGenerationSettingsBuilder generation) { List> local = generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); - changed |= addUnique(local, BiomeSurfaceFeature.placedFeature()); + changed |= StoneReplacer.placeUniqueAt(local, StoneReplacer.placedFeature(), 0); + changed |= StoneReplacer.placeUniqueAt(local, BiomeSurfaceFeature.placedFeature(), 1); List> top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java index 0e9cadd4..f0d4870b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -60,6 +60,23 @@ static Holder placedFeature() { return placedFeature; } + static boolean placeUniqueAt(List> features, + Holder feature, int index) { + if (feature == null) return false; + int current = -1; + for (int candidate = 0; candidate < features.size(); candidate++) { + if (features.get(candidate).value() == feature.value()) { + current = candidate; + break; + } + } + int target = Math.min(index, features.size() - (current >= 0 ? 1 : 0)); + if (current == target) return false; + if (current >= 0) features.remove(current); + features.add(target, feature); + return true; + } + static boolean removeVanillaMatchingStoneFeatures(List> features) { return features.removeIf(StoneReplacer::isVanillaMatchingStoneFeature); } diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg index 021a1058..dbe340ec 100644 --- a/src/main/resources/META-INF/accesstransformer.cfg +++ b/src/main/resources/META-INF/accesstransformer.cfg @@ -1,4 +1,4 @@ -public-f net.minecraft.world.level.chunk.ChunkGenerator f_62137_ # biomeSource -public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator f_188607_ # globalFluidPicker -public-f net.minecraft.world.level.biome.Biome f_47438_ # generationSettings -public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration f_68128_ # validBlocks +public-f net.minecraft.world.level.chunk.ChunkGenerator biomeSource +public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator globalFluidPicker +public-f net.minecraft.world.level.biome.Biome generationSettings +public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration validBlocks diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index 149cf617..d00a284d 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -6,7 +6,7 @@ # The name of the mod loader type to load - for regular FML @Mod mods it should be javafml modLoader="javafml" #mandatory # A version range to match for said mod loader - for regular FML @Mod it will be the forge version -loaderVersion="[45,)" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions. +loaderVersion="${loader_version_range}" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions. # The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. # Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. license="LGPL-2.1" @@ -17,7 +17,7 @@ issueTrackerURL="https://github.com/SkyBlade1978/OreSpawn/issues" #optional # The modid of the mod modId="orespawn" #mandatory # The version number of the mod -version="${file.jarVersion}" #mandatory +version="${version}" #mandatory # A display name for the mod displayName="MMD OreSpawn" #mandatory # A URL to query for updates for this mod. See the JSON update specification https://docs.minecraftforge.net/en/latest/misc/updatechecker/ @@ -47,7 +47,7 @@ modId="forge" #mandatory # Does this dependency have to exist - if not, ordering below must be specified mandatory=true #mandatory # The version range of the dependency -versionRange="[45,)" #mandatory +versionRange="${forge_version_range}" #mandatory # An ordering relationship for the dependency - BEFORE or AFTER required if the dependency is not mandatory # BEFORE - This mod is loaded BEFORE the dependency # AFTER - This mod is loaded AFTER the dependency @@ -59,6 +59,6 @@ side="BOTH" modId="minecraft" mandatory=true # This version range declares a minimum of the current minecraft version up to but not including the next major version -versionRange="[1.19.4,1.20)" +versionRange="${minecraft_version_range}" ordering="NONE" side="BOTH" diff --git a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java new file mode 100644 index 00000000..f54d9fa4 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -0,0 +1,98 @@ +package zone.moddev.mc.orespawn; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +class ReleaseWorkflowContractTest { + @Test + void usesOreSpawnSpecificMavenNamespace() throws Exception { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + properties.load(input); + } + assertEquals("zone.moddev.mc.orespawn", properties.getProperty("mod_group")); + } + + @Test + void verifiesGeneratedMavenCoordinatesBeforeCheckAndPublication() throws Exception { + Path buildFile = Paths.get("build.gradle"); + String build = new String(Files.readAllBytes(buildFile), StandardCharsets.UTF_8); + assertTrue(build.contains("tasks.register('verifyMavenCoordinates')")); + assertTrue(build.contains("generatePomFileForMavenJavaPublication")); + assertTrue(build.contains("dependsOn tasks.named('verifyMavenCoordinates')")); + assertTrue(build.contains("expectedMavenCoordinate")); + } + + @Test + void hostedWorkflowsUseThePinnedTemurinJdkForGradleAndCompilation() throws Exception { + for (String workflow : new String[] { "ci.yml", "codeql-analysis.yml" }) { + String text = new String(Files.readAllBytes( + Paths.get(".github", "workflows", workflow)), StandardCharsets.UTF_8); + int jobCount = workflow.equals("ci.yml") ? 2 : 1; + int gradleInvocationCount = workflow.equals("ci.yml") ? 3 : 1; + assertEquals(jobCount * 3, occurrences(text, "actions/setup-java@"), + workflow + " must install Mavenizer, launcher, and production JDKs per job"); + assertTrue(text.contains("distribution: temurin"), + workflow + " must use Temurin"); + assertEquals(jobCount, occurrences(text, "java-version: '25.0.3+9.0.LTS'"), + workflow + " must install the exact Mavenizer Java runtime"); + assertEquals(jobCount, occurrences(text, "java-version: '8.0.502+7'"), + workflow + " must install the exact legacy launcher toolchain"); + assertEquals(jobCount, occurrences(text, "java-version: '17.0.1+12'"), + workflow + " must install the exact qualified Java runtime"); + assertTrue(text.lastIndexOf("java-version: '17.0.1+12'") + > text.lastIndexOf("java-version: '25.0.3+9.0.LTS'"), + workflow + " must leave Java 17 as JAVA_HOME"); + assertTrue(text.lastIndexOf("java-version: '17.0.1+12'") + > text.lastIndexOf("java-version: '8.0.502+7'"), + workflow + " must install Java 17 last so it remains JAVA_HOME"); + assertEquals(gradleInvocationCount, occurrences(text, + "-Dorg.gradle.java.installations.paths="), + workflow + " must limit Gradle discovery to the pinned JDKs"); + assertEquals(gradleInvocationCount, occurrences(text, + "$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64"), + workflow + " must use only the explicit pinned JDK paths"); + assertEquals(gradleInvocationCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-detect=false"), + workflow + " must reject preinstalled runner toolchains"); + assertEquals(gradleInvocationCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-download=false"), + workflow + " must not silently replace pinned toolchains"); + assertFalse(text.contains("distribution: microsoft"), + workflow + " must not replace the exact Temurin Gradle runtime"); + } + } + + @Test + void codeQlUsesABoundedCachePreservingCompileRetry() throws Exception { + String text = new String(Files.readAllBytes( + Paths.get(".github", "workflows", "codeql-analysis.yml")), StandardCharsets.UTF_8); + assertTrue(text.contains("gradle_args=("), "CodeQL must pass Gradle options as an argument vector"); + assertTrue(text.contains("for attempt in 1 2 3; do"), + "CodeQL must bound transient Mavenizer download retries"); + assertTrue(text.contains("./gradlew \"${gradle_args[@]}\""), + "CodeQL retries must preserve exact Gradle arguments"); + assertTrue(text.contains("failed after $attempt attempts"), + "CodeQL must fail rather than hide a persistent bootstrap defect"); + } + + private static int occurrences(String text, String needle) { + int count = 0; + int offset = 0; + while ((offset = text.indexOf(needle, offset)) >= 0) { + count++; + offset += needle.length(); + } + return count; + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java index b690f7b1..96fbf8be 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java @@ -13,6 +13,33 @@ import net.minecraft.resources.ResourceLocation; class WorldgenProviderTest { + @Test + void terrainHostContractRetainsNaturalSourceOrder() { + ResourceLocation dimension = id("surfaceprobe:the_end"); + WorldgenProvider provider = WorldgenProvider.builder("surfaceprobe", 1) + .terrainDimension(dimension, terrain -> terrain + .hostBlock(id("minecraft:dirt")) + .hostBlock(id("minecraft:grass_block")) + .hostBlock(id("minecraft:coarse_dirt")) + .hostBlock(id("minecraft:podzol")) + .hostBlock(id("minecraft:rooted_dirt")) + .hostBlock(id("minecraft:gravel")) + .hostBlock(id("minecraft:sand")) + .hostBlock(id("minecraft:red_sand")) + .hostBlock(id("minecraft:clay")) + .hostBlock(id("minecraft:terracotta"))) + .build(); + + assertEquals("[\"minecraft:dirt\",\"minecraft:grass_block\"," + + "\"minecraft:coarse_dirt\",\"minecraft:podzol\"," + + "\"minecraft:rooted_dirt\",\"minecraft:gravel\"," + + "\"minecraft:sand\",\"minecraft:red_sand\"," + + "\"minecraft:clay\",\"minecraft:terracotta\"]", + provider.toJson().getAsJsonObject("terrain_dimensions") + .getAsJsonObject(dimension.toString()) + .getAsJsonArray("host_blocks").toString()); + } + @Test void serializesTypedSchemaFourProvider() { ResourceLocation overworld = id("minecraft:overworld"); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/ClientTextFieldPersistenceTest.java b/src/test/java/zone/moddev/mc/orespawn/client/ClientTextFieldPersistenceTest.java new file mode 100644 index 00000000..f468dc11 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/ClientTextFieldPersistenceTest.java @@ -0,0 +1,54 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.network.chat.Component; + +class ClientTextFieldPersistenceTest { + private static final Path CLIENT_SOURCE = Paths.get( + "src", "main", "java", "zone", "moddev", "mc", "orespawn", "client"); + private static final Pattern VALUE_BEFORE_MAX_LENGTH = Pattern.compile( + "(?s)\\b([A-Za-z_$][A-Za-z0-9_$]*)\\.setValue\\([^;]*;" + + "\\s*\\1\\.setMaxLength\\("); + + @Test + void everyTextFieldSetsItsMaximumBeforeLoadingSavedText() throws Exception { + List unsafe = 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_MAX_LENGTH.matcher(text).find()) { + unsafe.add(source.getFileName().toString()); + } + } + } + + assertTrue(unsafe.isEmpty(), + "Text fields must set their maximum length before loading saved text: " + unsafe); + } + + @Test + void targetTextFieldRetainsAValueLongerThanTheVanillaDefault() { + String value = "minecraft:stone,minecraft:granite,minecraft:diorite,minecraft:andesite"; + EditBox field = new EditBox(null, 0, 0, 200, 20, Component.literal("host_blocks")); + + field.setMaxLength(1024); + field.setValue(value); + + assertEquals(value, field.getValue()); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/documentation/DocumentationExporterTest.java b/src/test/java/zone/moddev/mc/orespawn/documentation/DocumentationExporterTest.java index f477f277..f3cf94ad 100644 --- a/src/test/java/zone/moddev/mc/orespawn/documentation/DocumentationExporterTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/documentation/DocumentationExporterTest.java @@ -1,11 +1,14 @@ package zone.moddev.mc.orespawn.documentation; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -17,16 +20,23 @@ class DocumentationExporterTest { @Test void exportsCompleteGuideAndDoesNotOverwriteExistingFiles() throws Exception { int firstExport = DocumentationExporter.exportMissing(temporaryDirectory); - assertTrue(firstExport >= 19); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("README.md"))); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("DEVELOPER_GUIDE.md"))); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("BIOMES.md"))); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("examples/examplemod-orespawn.json"))); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("schemas/orespawn-provider.schema.json"))); + Set trackedFiles = relativeFiles(Paths.get("docs"), Paths.get("docs")); + Set exportedFiles = relativeFiles(temporaryDirectory, temporaryDirectory); + assertEquals(trackedFiles.size(), firstExport); + assertEquals(trackedFiles, exportedFiles); Path readme = temporaryDirectory.resolve("README.md"); Files.write(readme, "local note".getBytes(StandardCharsets.UTF_8)); assertEquals(0, DocumentationExporter.exportMissing(temporaryDirectory)); assertEquals("local note", new String(Files.readAllBytes(readme), StandardCharsets.UTF_8)); } + + private static Set relativeFiles(Path root, Path current) throws Exception { + try (Stream paths = Files.walk(current)) { + return paths.filter(Files::isRegularFile) + .map(root::relativize) + .map(path -> path.toString().replace('\\', '/')) + .collect(Collectors.toSet()); + } + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java index 655d25cc..4961f94f 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -12,6 +12,7 @@ class BiomeSurfaceFeatureOrderTest { @Test void surfacesRunBeforeStructuresAndVegetationWhileFlatBedrockStaysLast() { + StoneReplacer.registerConfiguredFeature(); BiomeSurfaceFeature.registerConfiguredFeature(); FlatBedrockFeature.registerConfiguredFeature(); BiomeGenerationSettingsBuilder generation = new BiomeGenerationSettingsBuilder( @@ -23,7 +24,9 @@ void surfacesRunBeforeStructuresAndVegetationWhileFlatBedrockStaysLast() { Holder bedrock = FlatBedrockFeature.placedFeature(); var local = generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); var top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); - assertTrue(local.stream().anyMatch(feature -> feature.value() == surfaces.value())); + assertTrue(local.size() >= 2); + assertTrue(local.get(0).value() == StoneReplacer.placedFeature().value()); + assertTrue(local.get(1).value() == surfaces.value()); assertFalse(local.stream().anyMatch(feature -> feature.value() == bedrock.value())); assertTrue(top.stream().anyMatch(feature -> feature.value() == bedrock.value())); assertFalse(top.stream().anyMatch(feature -> feature.value() == surfaces.value())); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java index 9bdef311..eac3160a 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -37,7 +37,7 @@ static void bootstrapMinecraftRegistries() { } @Test - void cyanoSamplerMatchesPublishedMineralogy540AndSealedVectors() throws Exception { + void cyanoSamplerMatchesPublishedMineralogy530AndSealedVectors() throws Exception { Block[] igneous = { Blocks.STONE, Blocks.OBSIDIAN, Blocks.NETHERRACK }; Block[] metamorphic = { Blocks.COBBLESTONE, Blocks.MOSSY_COBBLESTONE }; Block[] sedimentary = { Blocks.SANDSTONE, Blocks.GRAVEL, Blocks.COAL_ORE, @@ -45,38 +45,35 @@ void cyanoSamplerMatchesPublishedMineralogy540AndSealedVectors() throws Exceptio MessageDigest sealed = MessageDigest.getInstance("SHA-256"); String configuredPath = System.getProperty("orespawn.mineralogy5Oracle", ""); - Path oracle = configuredPath.trim().isEmpty() ? null : Paths.get(configuredPath); - PublishedMineralogy published = oracle != null && Files.isRegularFile(oracle) - ? PublishedMineralogy.open(oracle) : null; + assertTrue(!configuredPath.trim().isEmpty(), + "The direct published Mineralogy 5.4.0 oracle is mandatory"); + Path oracle = Paths.get(configuredPath); + assertTrue(Files.isRegularFile(oracle), "Configured Mineralogy oracle is missing: " + oracle); + PublishedMineralogy published = PublishedMineralogy.open(oracle); try { - if (published != null) published.configure(9, igneous, metamorphic, sedimentary); + published.configure(9, igneous, metamorphic, sedimentary); for (long seed : new long[] { 0L, -4965128775892001975L }) { Geology os4 = new Geology(seed, 128.0D, 37.25D, 9, false, states(igneous), states(metamorphic), states(sedimentary)); - PublishedSampler sampler = published == null ? null : published.newSampler(seed, 128.0D, 37.25D); + PublishedSampler sampler = published.newSampler(seed, 128.0D, 37.25D); for (int x : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { for (int z : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { for (int y = 0; y < 256; y += 7) { Block actual = os4.getStoneAt(x, y, z); update(sealed, seed, x, y, z, actual); - if (sampler != null) { - assertEquals(sampler.getStoneAt(x, y, z), actual, - "Published Mineralogy 5.4.0 mismatch at " - + seed + ":" + x + ":" + y + ":" + z); - } + assertEquals(sampler.getStoneAt(x, y, z), actual, + "Published Mineralogy 5.4.0 mismatch at " + + seed + ":" + x + ":" + y + ":" + z); } } } } } finally { - if (published != null) published.close(); + published.close(); } assertEquals(SEALED_VECTOR_SHA256, hex(sealed.digest()), "The sealed vector digest is generated from the exact published Mineralogy 5.4.0 sampler"); - if (oracle != null) { - assertTrue(Files.isRegularFile(oracle), "Configured Mineralogy oracle is missing: " + oracle); - } } private static void update(MessageDigest digest, long seed, int x, int y, int z, Block block) { diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/StableLayerHeightEligibilityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/StableLayerHeightEligibilityTest.java new file mode 100644 index 00000000..3e33d9f5 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/StableLayerHeightEligibilityTest.java @@ -0,0 +1,49 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Blocks; + +import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig.GeomeDefinition; +import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig.RockEntry; + +class StableLayerHeightEligibilityTest { + static { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + void rockBoundsUseActualWorldYWhileFormationIdentityRemainsShifted() { + BakedGeomeConfig config = netherFloorConfig(); + GeomeGeology geology = new GeomeGeology(0L, config); + double[] geomeScores = { 1.0D }; + + assertEquals(Blocks.BASALT, + geology.getStoneAt(0, geomeScores, -64, 0L, 0, 1, 0), + "a legal Nether Y must not fall back to Stone when waviness shifts its formation below min_y"); + assertEquals(Blocks.STONE, + geology.getStoneAt(0, geomeScores, 64, 0L, 0, -1, 0), + "a shifted formation inside the range must not make an illegal actual Y eligible"); + } + + private static BakedGeomeConfig netherFloorConfig() { + GeomeDefinition[] geomes = { + new GeomeDefinition("test:nether", 1.0D, new double[] { 0.0D, 0.0D, 0.0D, 1.0D }) + }; + RockEntry[] rocks = { + new RockEntry(Blocks.BASALT.defaultBlockState(), RockFamily.IGNEOUS_VOLCANIC, + 24, 68, 0, 127, 1.0D, true, new double[] { 1.0D }) + }; + FormationSettings formations = new FormationSettings(FormationSettings.Algorithm.STABLE_LAYERS, + 32.0D, 8192.0D, 8, 512.0D, 96.0D, 24.0D, 3, 0.85D); + return new BakedGeomeConfig(geomes, 384.0D, 1.15D, 0.9D, 0.45D, + Collections.emptyMap(), Collections.emptyMap(), rocks, formations); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java index f54290d8..7f30157d 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java @@ -3,12 +3,17 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Collections; +import java.util.LinkedHashSet; + import org.junit.jupiter.api.Test; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; class StoneReplacerTest { @Test @@ -38,4 +43,22 @@ void explicitlyConfiguredCustomDimensionsCanSuppressMatchingStoneFeatures() { assertTrue(TerrainFeaturePolicy.shouldSuppressVanillaMatchingStoneFeature( moon, true, true)); } + + @Test + void invalidTerrainHostsRemainUnsafeEvenWhenDeclared() { + LinkedHashSet hosts = new LinkedHashSet<>(); + hosts.add(Blocks.AIR); + hosts.add(Blocks.WATER); + hosts.add(Blocks.BEDROCK); + hosts.add(Blocks.DIRT); + BakedTerrainDimension terrain = new BakedTerrainDimension( + ResourceKey.create(Registries.DIMENSION, + new ResourceLocation("surfaceprobe", "the_end")), + Collections.emptySet(), Collections.emptySet(), hosts); + + assertFalse(terrain.isReplaceable(Blocks.AIR.defaultBlockState())); + assertFalse(terrain.isReplaceable(Blocks.WATER.defaultBlockState())); + assertFalse(terrain.isReplaceable(Blocks.BEDROCK.defaultBlockState())); + assertTrue(terrain.isReplaceable(Blocks.DIRT.defaultBlockState())); + } }