diff --git a/.gitattributes b/.gitattributes index 83eed3b..c6ea9f8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,10 @@ *.jar binary *.png binary *.zip binary + +# Keep the native generated defaults byte-for-byte for log-control checks. +src/bootstrapTest/defaultconfigs/config/forge-client.toml whitespace=-blank-at-eof +src/bootstrapTest/defaultconfigs/defaultconfigs/forge-server.toml whitespace=-blank-at-eof + +# Keep the checksum-verified Mavenizer fixture byte-for-byte. +ci-fixtures/tools/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0738b69..e4d44cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,113 @@ -name: Sky's Terrain Smoother 1.12 CI +name: Sky's Terrain Smoother CI on: push: branches: - - master-1.12.2 + - 'master-*' - 'feature/**' pull_request: branches: - - master-1.12.2 + - 'master-*' permissions: contents: read concurrency: - group: skysterrainsmoother-1.12-${{ github.workflow }}-${{ github.ref }} + group: skysterrainsmoother-${{ 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 8 compilation toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 25 Gradle runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Pin production Java for the dependency builds + run: | + echo "SGS_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + echo "SBP_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + echo "STS_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Read pinned dependency sources + id: content + run: | + set -euo pipefail + grass="$(sed -n 's/^grass.commit=//p' gradle/content-dependencies.properties)" + [[ "$grass" =~ ^[0-9a-f]{40}$ ]] + echo "grass=$grass" >> "$GITHUB_OUTPUT" + pieces="$(sed -n 's/^pieces.commit=//p' gradle/content-dependencies.properties)" + [[ "$pieces" =~ ^[0-9a-f]{40}$ ]] + echo "pieces=$pieces" >> "$GITHUB_OUTPUT" + - name: Check out SkysGrassSlabs + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: SkyBlade1978/SkysGrassSlabs + ref: ${{ steps.content.outputs.grass }} + path: dependencies/grass + - name: Check out SkysBuildingPieces + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: SkyBlade1978/SkysBuildingPieces + ref: ${{ steps.content.outputs.pieces }} + path: dependencies/pieces + - name: Build development dependencies + run: | + set -euo pipefail + (cd dependencies/grass && bash ./gradlew assemble --no-daemon --stacktrace) + (cd dependencies/pieces && bash ./gradlew assemble --no-daemon --stacktrace) + + + - name: Bootstrap Forge from an empty cache + shell: bash + env: + GRADLE_USER_HOME: ${{ runner.temp }}/skysterrainsmoother-cold-gradle + run: | + set -euo pipefail + test ! -e .gradle + test ! -e "$GRADLE_USER_HOME" + mkdir -p "$GRADLE_USER_HOME" + chmod +x ./gradlew + gradle_args=( + classes test -PgrassSlabsDirectory=dependencies/grass -PbuildingPiecesDirectory=dependencies/pieces + --no-daemon --no-build-cache --stacktrace --max-workers=2 + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_21_X64,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew "${gradle_args[@]}" + + - name: Verify same-cache bootstrap offline + shell: bash + env: + GRADLE_USER_HOME: ${{ runner.temp }}/skysterrainsmoother-cold-gradle + ORESPAWN_MAVENIZER_OFFLINE: "true" + run: | + set -euo pipefail + gradle_args=( + classes test -PgrassSlabsDirectory=dependencies/grass -PbuildingPiecesDirectory=dependencies/pieces + --rerun-tasks --offline --no-daemon --no-build-cache --stacktrace --max-workers=2 + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_21_X64,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew "${gradle_args[@]}" + build: name: Build, test, and audit runs-on: ubuntu-latest @@ -26,27 +117,44 @@ jobs: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Install Java 8 toolchain + - name: Read target build metadata + id: target + shell: bash + run: | + set -euo pipefail + value() { sed -n "s/^$1=//p" gradle.properties; } + toolchain="$(value java_toolchain_version)" + selector="$(value java_setup_version)" + echo "java_setup_version=${selector:-$toolchain}" >> "$GITHUB_OUTPUT" + echo "java_version=$(value java_version)" >> "$GITHUB_OUTPUT" + echo "minecraft_version=$(value minecraft_version)" >> "$GITHUB_OUTPUT" + echo "gradle_java_version=${selector:-$(value gradle_java_toolchain_version)}" >> "$GITHUB_OUTPUT" + echo "artifact_prefix=SkysTerrainSmoother-$(value mod_version)" >> "$GITHUB_OUTPUT" + + - name: Install target Java toolchain uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: '8.0.502+7' + java-version: ${{ steps.target.outputs.java_setup_version }} - name: Pin production Java + if: steps.target.outputs.java_version == '25' run: | - echo "SGS_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" - echo "SBP_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" - echo "STS_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + echo "SGS_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + echo "SBP_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + echo "STS_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV" - - name: Install Java 17 for Gradle + - name: Install Java for Gradle + if: steps.target.outputs.gradle_java_version != steps.target.outputs.java_setup_version uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: - distribution: microsoft - java-version: '17' + distribution: temurin + java-version: ${{ steps.target.outputs.gradle_java_version }} - name: Set up Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + - name: Read pinned dependency sources id: content run: | @@ -72,9 +180,9 @@ jobs: - name: Build development dependencies run: | set -euo pipefail - chmod +x dependencies/*/gradlew - (cd dependencies/grass && ./gradlew jar --no-daemon --stacktrace) - (cd dependencies/pieces && ./gradlew jar --no-daemon --stacktrace) + (cd dependencies/grass && bash ./gradlew assemble --no-daemon --stacktrace) + (cd dependencies/pieces && bash ./gradlew assemble --no-daemon --stacktrace) + - name: Make the wrapper executable run: chmod +x ./gradlew @@ -86,19 +194,18 @@ jobs: - name: Replay artifact checks offline run: >- - ./gradlew assemble verifyReleaseArtifacts writeReleaseChecksums - -PgrassSlabsDirectory=dependencies/grass -PbuildingPiecesDirectory=dependencies/pieces --offline --no-daemon --stacktrace + ./gradlew assemble verifyReleaseArtifacts writeReleaseChecksums -PgrassSlabsDirectory=dependencies/grass -PbuildingPiecesDirectory=dependencies/pieces --offline --no-daemon --stacktrace - name: Upload audited release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: SkysTerrainSmoother-1.12.2-${{ github.sha }} + name: SkysTerrainSmoother-${{ steps.target.outputs.minecraft_version }}-${{ github.sha }} if-no-files-found: error retention-days: 30 path: | - build/libs/SkysTerrainSmoother-0.1.0.112021.jar - build/libs/SkysTerrainSmoother-0.1.0.112021-sources.jar - build/libs/SkysTerrainSmoother-0.1.0.112021-javadoc.jar + build/libs/SkysTerrainSmoother-0.1.0.2603001.jar + build/libs/SkysTerrainSmoother-0.1.0.2603001-sources.jar + build/libs/SkysTerrainSmoother-0.1.0.2603001-javadoc.jar build/release/SHA256SUMS CHANGELOG.md @@ -106,7 +213,7 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: SkysTerrainSmoother-1.12.2-diagnostics-${{ github.sha }} + name: SkysTerrainSmoother-${{ steps.target.outputs.minecraft_version }}-diagnostics-${{ github.sha }} if-no-files-found: ignore retention-days: 14 path: | diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2212bab..6465f2b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -3,11 +3,11 @@ name: CodeQL on: push: branches: - - master-1.12.2 + - 'master-*' - 'feature/**' pull_request: branches: - - master-1.12.2 + - 'master-*' schedule: - cron: '47 7 * * 4' @@ -26,27 +26,44 @@ jobs: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Install Java 8 toolchain + - name: Read target build metadata + id: target + shell: bash + run: | + set -euo pipefail + value() { sed -n "s/^$1=//p" gradle.properties; } + toolchain="$(value java_toolchain_version)" + selector="$(value java_setup_version)" + echo "java_setup_version=${selector:-$toolchain}" >> "$GITHUB_OUTPUT" + echo "java_version=$(value java_version)" >> "$GITHUB_OUTPUT" + echo "minecraft_version=$(value minecraft_version)" >> "$GITHUB_OUTPUT" + echo "gradle_java_version=${selector:-$(value gradle_java_toolchain_version)}" >> "$GITHUB_OUTPUT" + echo "artifact_prefix=SkysTerrainSmoother-$(value mod_version)" >> "$GITHUB_OUTPUT" + + - name: Install target Java toolchain uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: '8.0.502+7' + java-version: ${{ steps.target.outputs.java_setup_version }} - name: Pin production Java + if: steps.target.outputs.java_version == '25' run: | - echo "SGS_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" - echo "SBP_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" - echo "STS_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + echo "SGS_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + echo "SBP_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + echo "STS_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV" - - name: Install Java 17 for Gradle + - name: Install Java for Gradle + if: steps.target.outputs.gradle_java_version != steps.target.outputs.java_setup_version uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: - distribution: microsoft - java-version: '17' + distribution: temurin + java-version: ${{ steps.target.outputs.gradle_java_version }} - name: Set up Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + - name: Read pinned dependency sources id: content run: | @@ -72,9 +89,9 @@ jobs: - name: Build development dependencies run: | set -euo pipefail - chmod +x dependencies/*/gradlew - (cd dependencies/grass && ./gradlew jar --no-daemon --stacktrace) - (cd dependencies/pieces && ./gradlew jar --no-daemon --stacktrace) + (cd dependencies/grass && bash ./gradlew assemble --no-daemon --stacktrace) + (cd dependencies/pieces && bash ./gradlew assemble --no-daemon --stacktrace) + - name: Initialize CodeQL uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 9066817..b5525a6 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -321,9 +321,8 @@ jobs: - name: Build development dependencies run: | set -euo pipefail - chmod +x dependencies/*/gradlew - (cd dependencies/grass && ./gradlew jar --no-daemon --stacktrace) - (cd dependencies/pieces && ./gradlew jar --no-daemon --stacktrace) + (cd dependencies/grass && bash ./gradlew assemble --no-daemon --stacktrace) + (cd dependencies/pieces && bash ./gradlew assemble --no-daemon --stacktrace) - name: Build, test, and audit once env: @@ -518,9 +517,8 @@ jobs: - name: Build development dependencies run: | set -euo pipefail - chmod +x dependencies/*/gradlew - (cd dependencies/grass && ./gradlew jar --no-daemon --stacktrace) - (cd dependencies/pieces && ./gradlew jar --no-daemon --stacktrace) + (cd dependencies/grass && bash ./gradlew assemble --no-daemon --stacktrace) + (cd dependencies/pieces && bash ./gradlew assemble --no-daemon --stacktrace) - name: Download immutable release artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 94f7c7f..82501ed 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -19,7 +19,11 @@ jobs: set -euo pipefail version="$(sed -n 's/^mod_version=//p' gradle.properties)" test "$GITHUB_REF_NAME" = "$version" - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.112021$ ]] + minecraft="$(sed -n 's/^minecraft_version=//p' gradle.properties)" + loader="$(sed -n 's/^loader_code=//p' gradle.properties)" + IFS=. read -r major minor patch <<<"$minecraft" + printf -v suffix '%s%02d%02d%s' "$major" "$((10#$minor))" "$((10#${patch:-0}))" "$loader" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.${suffix}$ ]] checks="$(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')" test "$checks" -gt 0 echo "verifyReleaseArtifacts passed in CI. This tag check publishes nothing." diff --git a/.github/workflows/validate-gradle-build.yml b/.github/workflows/validate-gradle-build.yml index 8959c1a..d71fd0e 100644 --- a/.github/workflows/validate-gradle-build.yml +++ b/.github/workflows/validate-gradle-build.yml @@ -3,11 +3,11 @@ name: Validate Gradle Wrapper on: push: branches: - - master-1.12.2 + - 'master-*' - 'feature/**' pull_request: branches: - - master-1.12.2 + - 'master-*' permissions: contents: read diff --git a/.gitignore b/.gitignore index 30e9b77..8d22558 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,7 @@ agent_notes/ # Files from Forge MDK forge*changelog.txt /README.txt + +# Python caches can contain local source paths. +__pycache__/ +*.py[cod] diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b3b8e..28ea402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## 0.1.0.2603001 + +- Updated Terrain Smoother for Minecraft 26.3 on Forge. + +## 0.1.0.2602001 + +- Updated Terrain Smoother for Minecraft 26.2 on Forge. + +## 0.1.0.2601021 + +- Added support for Minecraft 26.1.2 on Forge. Update Grass Slabs and Building Pieces together with Terrain Smoother. +- Sand slabs retain their colour, orientation and waterlogging when you move an existing world. Smoothing still applies only to new terrain. + +## 0.1.0.121011 (unreleased) + +- Bring surface and cave smoothing to Minecraft 1.21.1 on Forge. +- Use the matching Grass Slabs and Building Pieces versions. + +## 0.1.0.120061 (unreleased) + +- Bring surface and cave smoothing to Minecraft 1.20.6. +- Use the matching Grass Slabs and Building Pieces versions. + +## 0.1.0.120011 (unreleased) + +- Bring surface and cave smoothing to Minecraft 1.20.1. +- Use the matching Grass Slabs and Building Pieces versions. + +## 0.1.0.119041 (unreleased) + +- Bring surface and cave smoothing to Minecraft 1.19.4. +- Use the matching Grass Slabs and Building Pieces versions. + +## 0.1.0.118021 (unreleased) + +- Bring surface and cave smoothing to Minecraft 1.18.2. +- Include the lower caves and taller terrain in newly generated chunks. +- Use the matching Grass Slabs and Building Pieces versions. + +## 0.1.0.117011 (unreleased) + +- Bring surface and cave smoothing to Minecraft 1.17.1. +- Use the matching Grass Slabs and Building Pieces versions. + +## 0.1.0.116051 (unreleased) + +- Bring surface and cave smoothing to Minecraft 1.16.5. +- Use the matching Grass Slabs and Building Pieces versions. + +## 0.1.0.115021 (unreleased) + +- Bring surface and cave smoothing to Minecraft 1.15.2. +- Use the matching Grass Slabs and Building Pieces releases. + +## 0.1.0.114041 + +- Added Minecraft 1.14.4 support, including cave floors and all three smoothing levels. +- Kept existing sand slabs, colours, orientations and custom item data when upgrading from 1.12.2. +- Moved settings to TOML while preserving choices from the old CFG file. +- Added the native Mods Config screen and kept changes pending until Minecraft restarts. +- Kept Mineralogy's slabs and Building Pieces' steps and corners working together. + ## 0.1.0.112021 - Added ingredient-based recipe unlocks, including slab recombination. diff --git a/README.md b/README.md index 232a3d2..bc8008b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ sandstone, sand and hardened clay. ## Smoothing levels -Set `worldgen.smoothingLevel` in `config/skysterrainsmoother.cfg`: +Set `worldgen.smoothingLevel` in `config/skysterrainsmoother-common.toml`: 1. **Slabs** - half-block transitions. 2. **Slabs and steps** - adds pieces covering half a slab's footprint. @@ -48,14 +48,15 @@ For BOP grass and soils, add the ## Installation and settings -For **Minecraft 1.12.2**, use **Forge 14.23.5.2859** and **Java 8**, with: +For **Minecraft 26.3**, use **Forge 66.0.2** and **Java 25**, with: -- Sky's Grass Slabs **1.1.2.112021**. -- Sky's Building Pieces **0.3.8.112021**. -- Sky's Terrain Smoother **0.1.0.112021**. +- Sky's Grass Slabs **1.2.0.2603001**. +- Sky's Building Pieces **0.3.9.2603001**. +- Sky's Terrain Smoother **0.1.0.2603001**. Install all three main jars on both the client and server. +The old CFG settings are copied once into TOML; the original file is kept. New configurations start at level 3. Existing saved choices are kept. Select the mod in **Mods > Config** to choose a level, turn smoothing off or disable Mineralogy support, then restart Minecraft. diff --git a/build.gradle b/build.gradle index bb90f98..fdf265a 100644 --- a/build.gradle +++ b/build.gradle @@ -10,10 +10,73 @@ plugins { id 'eclipse' id 'idea' id 'maven-publish' - id 'net.minecraftforge.renamer' version '1.1.5' id 'net.minecraftforge.gradle' version '7.0.34' } +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() +} + +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): + '9DA1F44821D89B16B3E181CA89057F44CD3FC3F3383EA5A16442872E67E30F35', + (mavenizerRuleManifest.asFile): + 'C765A5F65FB9CD176F3CE08CB0CFCA814D06E649940E2BF169CD8943AFC30B26', + (mavenizerSourcePatch.asFile): + 'BBD82C6BD96206DEA2FE2673F4D7E76FBA92884032455BBFD5CCD59045C5E271', + (mavenizerLicense.asFile): + '20C17D8B8C48A600800DFD14F95D5CB9FF47066A9641DDEAB48DC54AEC96E331', + (mavenizerReadme.asFile): + 'A611E2BF14E29761B99BF7D3F0FD44AF151DD09A69968C0CFAEC384BD0E7A666' +] + +// ForgeGradle invokes Mavenizer while configuring the project. Verify the +// complete five-file fixture before any Mavenizer code is allowed to run. +mavenizerFixtureChecksums.keySet().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 + }) +} + +// The Forge launcher uses the same Java version as Minecraft. +fgtools.configure('slimelauncher') { + javaLauncher.set(javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.ADOPTIUM + }) +} + group = project.mod_group version = project.mod_version base.archivesName = 'SkysTerrainSmoother' @@ -22,10 +85,14 @@ 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 minecraftVersionParts = project.minecraft_version.toString().split('\\.', -1) +if (!(minecraftVersionParts.length in 2..3) || !minecraftVersionParts.every { it ==~ /\d+/ }) { + throw new GradleException("minecraft_version must have two or three numeric parts: ${project.minecraft_version}") +} +def minecraftPatch = minecraftVersionParts.length == 3 ? minecraftVersionParts[2] : '0' def expectedTargetVersion = "${minecraftVersionParts[0]}" + "${minecraftVersionParts[1].padLeft(2, '0')}" + - "${minecraftVersionParts[2].padLeft(2, '0')}" + project.loader_code + "${minecraftPatch.padLeft(2, '0')}" + project.loader_code if (versionParts[3] != expectedTargetVersion) { throw new GradleException("mod_version target ${versionParts[3]} does not match ${expectedTargetVersion}") } @@ -33,34 +100,34 @@ if (versionParts[3] != expectedTargetVersion) { ext.functional_version = versionParts[0..2].join('.') ext.display_version = project.mod_version ext.release_tag = project.mod_version -def pinnedJava8 = providers.environmentVariable('STS_JAVA8_HOME') -// Gradle 9.6.1's ordinary language-level selector prefers the newest cached -// Java 8. This opt-in specific installation keeps local/CI qualification exact. -def exactJava8Spec = pinnedJava8.isPresent() ? +def pinnedJava25 = providers.environmentVariable('STS_JAVA25_HOME') +// Gradle 9.7.1's ordinary language-level selector prefers the newest cached +// Java 25. This opt-in specific installation keeps local/CI qualification exact. +def exactJava25Spec = pinnedJava25.isPresent() ? org.gradle.jvm.toolchain.internal.SpecificInstallationToolchainSpec.fromJavaHome( - project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava8.get())) : null + project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava25.get())) : null java { - toolchain.languageVersion = JavaLanguageVersion.of(8) + toolchain.languageVersion = JavaLanguageVersion.of(25) withSourcesJar() withJavadocJar() } tasks.withType(JavaCompile).configureEach { javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(8) + languageVersion = JavaLanguageVersion.of(25) } - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 options.encoding = 'UTF-8' options.compilerArgs.addAll(['-Xmaxerrs', '1000']) - if (pinnedJava8.isPresent()) { - javaCompiler = javaToolchains.compilerFor(exactJava8Spec) + if (pinnedJava25.isPresent()) { + javaCompiler = javaToolchains.compilerFor(exactJava25Spec) } } -def grassSlabsDirectory = file(providers.gradleProperty('grassSlabsDirectory').getOrElse('../..' + '/SkysGrassSlabs112/SkysGrassSlabs')) -def buildingPiecesDirectory = file(providers.gradleProperty('buildingPiecesDirectory').getOrElse('../..' + '/SkysBuildingPieces112/SkysBuildingPieces')) +def grassSlabsDirectory = file(providers.gradleProperty('grassSlabsDirectory').getOrElse('../..' + '/SkysGrassSlabs263/SkysGrassSlabs')) +def buildingPiecesDirectory = file(providers.gradleProperty('buildingPiecesDirectory').getOrElse('../..' + '/SkysBuildingPieces263/SkysBuildingPieces')) def contentLock=new Properties() file('gradle/content-dependencies.properties').withInputStream { contentLock.load(it) } @@ -73,7 +140,7 @@ def grassVersion = localGrassVersion.getOrElse(contentLock.getProperty('grass.ve def grassSlabsDevelopmentJar = new File(grassSlabsDirectory, - "build/libs-dev/SkysGrassSlabs-${grassVersion}-deobf.jar") + "build/libs/SkysGrassSlabs-${grassVersion}.jar") def localPiecesVersion = providers.gradleProperty('buildingPiecesCoreVersion') if (localPiecesVersion.isPresent() && @@ -82,25 +149,49 @@ if (localPiecesVersion.isPresent() && } def piecesVersion = localPiecesVersion.getOrElse(contentLock.getProperty('pieces.version')) def buildingPiecesDevelopmentJar = new File(buildingPiecesDirectory, - "build/libs-dev/SkysBuildingPieces-${piecesVersion}-deobf.jar") -def verifyContentDependencies=tasks.register('verifyContentDependencies') { + "build/libs/SkysBuildingPieces-${piecesVersion}.jar") +def verifyContentDependencies = tasks.register('verifyContentDependencies') { doLast { - [grass:grassSlabsDirectory,pieces:buildingPiecesDirectory].each { name,directory -> - Properties metadata=new Properties() - new File(directory,'gradle.properties').withInputStream { metadata.load(it) } - String expectedVersion = name == 'grass' ? grassVersion : name == 'pieces' ? piecesVersion : contentLock.getProperty(name + '.version') - if (metadata.mod_version != expectedVersion) { - String localHelp = name == 'pieces' && System.getenv('CI') == null ? - ' For a local candidate, supply both -PbuildingPiecesDirectory=' + - ' and -PbuildingPiecesCoreVersion=, then regenerate the Eclipse runs.' : '' + Map dependencies = [ + grass: [directory: grassSlabsDirectory, version: grassVersion, + override: localGrassVersion.isPresent(), artifact: 'SkysGrassSlabs'], + pieces: [directory: buildingPiecesDirectory, version: piecesVersion, + override: localPiecesVersion.isPresent(), artifact: 'SkysBuildingPieces'] + ] + dependencies.each { name, selected -> + File directory = selected.directory + Properties metadata = new Properties() + new File(directory, 'gradle.properties').withInputStream { metadata.load(it) } + if (metadata.mod_version != selected.version) { throw new GradleException("Content dependency version does not match its lock: ${name}" + - " (expected ${expectedVersion}, found ${metadata.mod_version})." + localHelp) + " (expected ${selected.version}, found ${metadata.mod_version}).") } - if(System.getenv('CI')!=null) { - Process git=new ProcessBuilder('git','-C',directory.path,'rev-parse','HEAD').start() - String sha=git.inputStream.getText('UTF-8').trim() - if(git.waitFor()!=0||sha!=contentLock.getProperty(name+'.commit')) - throw new GradleException('Content dependency source does not match its lock: '+name) + if (!selected.override) { + Process revision = new ProcessBuilder('git', '-C', directory.path, 'rev-parse', 'HEAD').start() + String commit = revision.inputStream.getText('UTF-8').trim() + if (revision.waitFor() != 0 || commit != contentLock.getProperty(name + '.commit')) { + throw new GradleException('Content dependency source does not match its lock: ' + name) + } + Process status = new ProcessBuilder('git', '-C', directory.path, + 'status', '--porcelain', '--untracked-files=no').start() + String changes = status.inputStream.getText('UTF-8').trim() + if (status.waitFor() != 0 || !changes.isEmpty()) { + throw new GradleException('The pinned content checkout has tracked changes: ' + name) + } + [ + [path: "build/libs/${selected.artifact}-${selected.version}.jar", key: '.sha256'], + [path: "build/libs/${selected.artifact}-${selected.version}.jar", key: '.development.sha256'] + ].each { artifact -> + File jar = new File(directory, artifact.path) + if (!jar.isFile()) { + throw new GradleException('Build the pinned content dependency first: ' + jar) + } + String checksum = MessageDigest.getInstance('SHA-256').digest(Files.readAllBytes(jar.toPath())) + .encodeHex().toString().toUpperCase(Locale.ROOT) + if (checksum != contentLock.getProperty(name + artifact.key)) { + throw new GradleException('Content dependency artifact does not match its lock: ' + jar.name) + } + } } } } @@ -122,28 +213,29 @@ tasks.named('compileTestJava', JavaCompile) { tasks.withType(Test).configureEach { useJUnitPlatform() workingDir = project.projectDir - if (pinnedJava8.isPresent()) javaLauncher = javaToolchains.launcherFor(exactJava8Spec) + if (pinnedJava25.isPresent()) javaLauncher = javaToolchains.launcherFor(exactJava25Spec) } tasks.withType(JavaExec).configureEach { - if (pinnedJava8.isPresent()) javaLauncher = javaToolchains.launcherFor(exactJava8Spec) + if (pinnedJava25.isPresent()) javaLauncher = javaToolchains.launcherFor(exactJava25Spec) } tasks.withType(Javadoc).configureEach { - if (pinnedJava8.isPresent()) javadocTool = javaToolchains.javadocToolFor(exactJava8Spec) - failOnError = false + if (pinnedJava25.isPresent()) javadocTool = javaToolchains.javadocToolFor(exactJava25Spec) + failOnError = true options.encoding = 'UTF-8' options.addStringOption('Xdoclint:none', '-quiet') options.addBooleanOption('notimestamp', true) } tasks.withType(AbstractArchiveTask).configureEach { + includeEmptyDirs = false preserveFileTimestamps = false reproducibleFileOrder = true } def archiveTextSuffixes = [ '.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.mcmeta', '.md', - '.properties', '.txt', '.xml' + '.properties', '.toml', '.txt', '.xml' ] def archiveTextPatterns = archiveTextSuffixes.collect { "**/*${it}".toString() } archiveTextPatterns.addAll([ @@ -157,49 +249,81 @@ def normalizeArchiveLineEndings = { details -> } def developmentRunDirectory = providers.gradleProperty('skysTerrainSmootherRunDirectory').getOrElse('run') + +def prepareContentDevelopmentRun = tasks.register('prepareContentDevelopmentRun') { + group = 'ide' + dependsOn verifyContentDependencies + doLast { + File mods = new File(file(developmentRunDirectory), 'mods') + Set expected = [grassSlabsDevelopmentJar.name, buildingPiecesDevelopmentJar.name] as Set + fileTree(mods).matching { include '*.jar' }.each { existing -> + if (existing.name ==~ /Skys(?:GrassSlabs|BuildingPieces)-[0-9].*\.jar/ && !expected.contains(existing.name)) { + throw new GradleException('An older content jar is already in the development profile: ' + existing) + } + if (existing.name.contains('integration-test') || existing.name.contains('native-checks')) { + throw new GradleException('Ordinary development runs must not contain runtime probes: ' + existing) + } + } + copy { + from grassSlabsDevelopmentJar, buildingPiecesDevelopmentJar + into mods + } + } +} + minecraft { mappings channel: project.mapping_channel, version: project.mapping_version runs { configureEach { + jvmArgs '--add-opens', 'java.base/java.lang.invoke=ALL-UNNAMED' workingDir.convention layout.projectDirectory.dir(developmentRunDirectory) - environment 'MC_VERSION', project.minecraft_version.toString() + systemProperty 'forge.logging.markers', 'REGISTRIES' systemProperty 'forge.logging.console.level', 'debug' - systemProperty 'fml.coreMods.load', 'zone.moddev.mc.skysbuildingpieces.legacy.LoaderPlugin' + mods { skysterrainsmoother { source sourceSets.main } } + environment 'MOD_CLASSES', + "${mod_id}%%${buildDir}/forge-run-mod-classes/main" + if (name.toLowerCase(Locale.ROOT).contains('client') && + providers.gradleProperty('forgeClientAssets').isPresent()) { + environment 'assetIndex', '34' + environment 'assetDirectory', providers.gradleProperty('forgeClientAssets').get() + if (providers.gradleProperty('forgeClientNatives').isPresent()) { + environment 'nativesDirectory', providers.gradleProperty('forgeClientNatives').get() + } + } } register('client') register('server') { args '--nogui' } } } -// Forge 1.12 discovers a development mod from one classes/resources root. -def forge12DevelopmentOutput = layout.buildDirectory.dir('forge12-development/main') -def syncForge10DevelopmentResources = tasks.register('syncForge10DevelopmentResources', Sync) { - group = 'ide' - dependsOn tasks.named('classes') - from tasks.named('compileJava', JavaCompile).flatMap { it.destinationDirectory } +def forgeRunModClassesDirectory = file("${buildDir}/forge-run-mod-classes/main") +def prepareForgeRunModClasses = tasks.register('prepareForgeRunModClasses', Sync) { + dependsOn tasks.named('classes'), prepareContentDevelopmentRun + from sourceSets.main.output.classesDirs from sourceSets.main.output.resourcesDir - into forge12DevelopmentOutput + into forgeRunModClassesDirectory } -tasks.configureEach { - if (name in ['runClient', 'runServer', 'runTestClient', 'runTestServer']) { - dependsOn syncForge10DevelopmentResources - doFirst { - if (pinnedJava8.isPresent()) executable = javaToolchains.launcherFor(exactJava8Spec).get().executablePath.asFile.path - Set splitMainOutputs = [ - tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile, - sourceSets.main.output.resourcesDir - ] as Set - classpath = files(forge12DevelopmentOutput, classpath.filter { - !splitMainOutputs.contains(it) - }) + +def isolatedForgeClasspath = { FileCollection classpath, boolean includeMain -> + Set splitOutputs = (sourceSets.main.output.files + [forgeRunModClassesDirectory]) + .collect { it.canonicalFile } as Set + FileCollection dependencies = classpath.filter { !splitOutputs.contains(it.canonicalFile) } + includeMain ? files(forgeRunModClassesDirectory, dependencies) : dependencies +} +tasks.withType(JavaExec).configureEach { JavaExec runTask -> + if (runTask.name in ['runClient', 'runServer']) { + runTask.dependsOn prepareForgeRunModClasses + runTask.doFirst { + runTask.classpath = isolatedForgeClasspath(runTask.classpath, true) + runTask.environment 'MOD_CLASSES', "${mod_id}%%${forgeRunModClassesDirectory}" } } } // Clone ForgeGradle's generated launch at execution time so verification runs -// use the same mappings and Java 8 runtime without exposing probe code to the +// use the same mappings and Java 25 runtime without exposing probe code to the // ordinary client or server classpath. -def configureFromForgeRun = { JavaExec process, String runTaskName -> +def configureFromForgeRun = { JavaExec process, String runTaskName, boolean includeMain = true -> process.actions.clear() process.dependsOn { JavaExec run = tasks.getByName(runTaskName) as JavaExec @@ -215,6 +339,8 @@ def configureFromForgeRun = { JavaExec process, String runTaskName -> List originalJvmArgs = new ArrayList<>(run.jvmArgs) Map originalSystemProperties = new LinkedHashMap<>(run.systemProperties) Map originalEnvironment = new LinkedHashMap<>(run.environment) + Map originalConfiguredEnvironment = includeMain ? null : + new LinkedHashMap<>(runOptions.environment.get()) FileCollection originalClasspath = files(run.classpath.files) String originalMinHeap = run.minHeapSize String originalMaxHeap = run.maxHeapSize @@ -226,17 +352,15 @@ def configureFromForgeRun = { JavaExec process, String runTaskName -> run.jvmArgs(process.jvmArgs) run.systemProperties(process.systemProperties) run.environment(process.environment) - Set splitMainOutputs = [ - tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile, - sourceSets.main.output.resourcesDir - ] as Set - run.setClasspath(files(forge12DevelopmentOutput, run.classpath.filter { - !splitMainOutputs.contains(it) - })) - if (pinnedJava8.isPresent()) run.executable = javaToolchains.launcherFor(exactJava8Spec).get().executablePath.asFile.path + String modClasses = includeMain ? "${mod_id}%%${forgeRunModClassesDirectory}" : '' + // Eclipse generation locks this map. Ordinary runs already have the merged output. + if (!includeMain) runOptions.environment.put('MOD_CLASSES', modClasses) + run.setClasspath(isolatedForgeClasspath(originalClasspath, includeMain)) + run.environment('MOD_CLASSES', modClasses) run.exec() } finally { runOptions.workingDir.set(originalConfiguredWorkingDir) + if (!includeMain) runOptions.environment.set(originalConfiguredEnvironment) run.workingDir(originalWorkingDir) run.setArgs(originalArgs) run.setJvmArgs(originalJvmArgs) @@ -278,7 +402,6 @@ dependencies { implementation files(grassSlabsDevelopmentJar, buildingPiecesDevelopmentJar) implementation minecraft.dependency( "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") - compileOnly 'org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' @@ -290,7 +413,7 @@ def compileIntegrationTestMod = tasks.register('compileIntegrationTestMod', Java group = 'verification' dependsOn tasks.named('classes') source fileTree('src/integrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + classpath = files(sourceSets.main.output, sourceSets.main.runtimeClasspath) destinationDirectory = integrationTestClasses options.encoding = 'UTF-8' } @@ -301,21 +424,15 @@ def integrationTestModJar = tasks.register('integrationTestModJar', Jar) { archiveFileName = 'skysterrainsmoother-integration-test.jar' destinationDirectory = layout.buildDirectory.dir('integration-test-mod') from integrationTestClasses - from 'src/integrationTest/resources' -} - -def packagedIntegrationTestModJar = renamer.classes(integrationTestModJar) { - map.from minecraft.dependency.toSrgFile - libraries.from tasks.named('jar', Jar).flatMap { it.archiveFile } - libraries.from sourceSets.main.compileClasspath - output = layout.buildDirectory.file( - 'integration-test-mod/skysterrainsmoother-integration-test-reobf.jar') + from('src/integrationTest/resources') { + exclude 'log4j2.xml' + } } tasks.register('preparePackagedIntegrationProbe') { group = 'verification' - description = 'Builds the reobfuscated probe used only in disposable packaged runtimes.' - dependsOn packagedIntegrationTestModJar + description = 'Builds the native probe used only in disposable packaged runtimes.' + dependsOn integrationTestModJar } def integrationRunDirectory = layout.buildDirectory.dir('integration-test-run') @@ -324,11 +441,19 @@ def prepareIntegrationTest = tasks.register('prepareIntegrationTest') { dependsOn integrationTestModJar doLast { File runDirectory = integrationRunDirectory.get().asFile - project.delete(runDirectory) + if (runDirectory.canonicalFile != new File(buildDir, 'integration-test-run').canonicalFile) { + throw new GradleException('Integration checks must stay inside the build directory') + } + if (new File(runDirectory, 'logs/latest.log').isFile()) { + throw new GradleException('Archive the previous runtime evidence before preparing another run') + } + runDirectory.mkdirs() + copy { from 'src/bootstrapTest/defaultconfigs'; into runDirectory } File mods = new File(runDirectory, 'mods') mods.mkdirs() project.copy { from integrationTestModJar.flatMap { it.archiveFile } + from grassSlabsDevelopmentJar, buildingPiecesDevelopmentJar into mods } new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') @@ -338,7 +463,7 @@ def prepareIntegrationTest = tasks.register('prepareIntegrationTest') { new File(runDirectory, 'server.properties').setText('''\ level-name=world level-seed=skys-terrain-smoother-integration -level-type=default +level-type=minecraft:normal online-mode=false server-ip=127.0.0.1 server-port=0 @@ -357,7 +482,6 @@ def createIntegrationProcess = { String phase, Object dependency -> dependsOn dependency workingDir integrationRunDirectory systemProperty 'skysterrainsmoother.integrationPhase', phase.toLowerCase(Locale.ROOT) - systemProperty 'log4j.configurationFile', file('src/integrationTest/resources/log4j2.xml').toURI().toString() configureFromForgeRun(delegate, 'runServer') doLast { File marker = new File(integrationRunDirectory.get().asFile, @@ -370,6 +494,15 @@ def createIntegrationProcess = { String phase, Object dependency -> if (values.getProperty("${phase.toLowerCase(Locale.ROOT)}_complete") != 'true') { throw new GradleException("${phase} integration did not complete: ${marker}") } + project.services.get(org.gradle.process.ExecOperations).exec { + commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'), + 'tools/native_client_log.py', '--server', '--directory', + integrationRunDirectory.get().asFile.absolutePath, '--phase', phase.toLowerCase(Locale.ROOT) + } + copy { + from new File(integrationRunDirectory.get().asFile, 'logs') + into new File(integrationRunDirectory.get().asFile, 'phase-logs/' + phase.toLowerCase(Locale.ROOT)) + } } } } @@ -400,15 +533,40 @@ tasks.register('integrationTest') { } } +def integrationClientRunName = providers.gradleProperty('terrainIntegrationClientRunName') + .getOrElse('integration-client-run') +if (!(integrationClientRunName ==~ /[a-z0-9-]+/)) { + throw new GradleException('Use a simple name for the client check directory') +} +def integrationClientDirectory = layout.buildDirectory.dir(integrationClientRunName) +def prepareIntegrationClient = tasks.register('prepareIntegrationClient') { + dependsOn integrationTestModJar, verifyContentDependencies + doLast { + File target = integrationClientDirectory.get().asFile + if (target.canonicalFile.parentFile != buildDir.canonicalFile) { + throw new GradleException('Client checks must stay inside the build directory') + } + if (new File(target, 'logs/latest.log').isFile()) { + throw new GradleException('Archive the previous client evidence before preparing another run') + } + target.mkdirs() + copy { from 'src/bootstrapTest/defaultconfigs'; into target } + copy { + from integrationTestModJar.flatMap { it.archiveFile }, grassSlabsDevelopmentJar, buildingPiecesDevelopmentJar + into new File(target, 'mods') + } + new File(target, 'options.txt').text = 'fullscreen:false\noverrideWidth:854\noverrideHeight:480\nmaxFps:30\npauseOnLostFocus:false\nrenderDistance:6\n' + } +} + tasks.register('integrationClientProcess', JavaExec) { group = 'verification' - dependsOn prepareIntegrationTest - workingDir integrationRunDirectory + dependsOn prepareIntegrationClient + workingDir integrationClientDirectory systemProperty 'skysterrainsmoother.integrationPhase', 'client' - systemProperty 'log4j.configurationFile', file('src/integrationTest/resources/log4j2.xml').toURI().toString() configureFromForgeRun(delegate, 'runClient') doLast { - if (!new File(integrationRunDirectory.get().asFile, 'terrain-smoother-client-pass.txt').isFile()) + if (!new File(integrationClientDirectory.get().asFile, 'terrain-smoother-client-pass.txt').isFile()) throw new GradleException('Client did not complete resource baking') } } @@ -419,24 +577,15 @@ tasks.named('check') { tasks.named('processResources', ProcessResources) { filteringCharset = 'UTF-8' - inputs.properties([ - version: project.version, - mcversion: project.minecraft_version, - modid: project.mod_id, - name: project.mod_name, - description: project.mod_description - ]) - filesMatching('mcmod.info') { - expand version: project.version, mcversion: project.minecraft_version, - modid: project.mod_id, name: project.mod_name, - description: project.mod_description - } + Map metadata = ['loader_version_range', 'mod_license', 'mod_id', 'mod_version', + 'mod_name', 'mod_authors', 'mod_description', 'forge_version_range', + 'minecraft_version_range'].collectEntries { key -> [(key): project.property(key)] } + inputs.properties(metadata) + filesMatching('META-INF/mods.toml') { expand metadata } filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } tasks.named('jar', Jar) { - archiveClassifier = 'deobf' - destinationDirectory = layout.buildDirectory.dir('libs-dev') from(['LICENSE', 'LICENSE.spdx', 'NOTICE']) { into 'META-INF' } manifest { attributes([ @@ -444,19 +593,14 @@ tasks.named('jar', Jar) { 'Implementation-Title': base.archivesName.get(), 'Implementation-Version': project.version, 'Implementation-Vendor': project.mod_authors, - 'Built-On-Java': '8', + 'Built-On-Java': '21', 'Built-On': "${project.minecraft_version}-${project.forge_version}" ]) } filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } -def releaseJar = renamer.classes(tasks.named('jar', Jar)) { - map.from minecraft.dependency.toSrgFile - libraries.from sourceSets.main.compileClasspath - archiveClassifier = null - output = layout.buildDirectory.file("libs/${base.archivesName.get()}-${project.version}.jar") -} +def releaseJar = tasks.named('jar', Jar) tasks.named('sourcesJar', Jar) { from(['LICENSE', 'LICENSE.spdx', 'NOTICE', 'BUILDINGBRICKS-MIT.txt']) { into 'META-INF' } @@ -467,10 +611,6 @@ tasks.named('javadocJar', Jar) { filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } -['apiElements', 'runtimeElements'].each { configurationName -> - configurations.named(configurationName) { artifacts.clear() } - artifacts { add(configurationName, releaseJar) } -} tasks.named('assemble') { dependsOn releaseJar, tasks.named('sourcesJar'), tasks.named('javadocJar') } @@ -486,12 +626,12 @@ def releaseFiles = provider { tasks.register('verifyReleaseMetadata') { group = 'verification' doLast { - if (project.minecraft_version != '1.12.2' || project.forge_version != '14.23.5.2859' || - project.mapping_channel != 'stable' || project.mapping_version != '39-1.12' || + if (project.minecraft_version != '26.3' || project.forge_version != '66.0.2' || + project.mapping_channel != 'official' || project.mapping_version != '26.3' || project.loader_name != 'forge' || project.loader_code != '1' || - project.java_version != '8' || project.java_toolchain_version != '8.0.502+7' || + project.java_version != '25' || project.java_toolchain_version != '25.0.3+9' || project.mod_id != 'skysterrainsmoother') { - throw new GradleException('Unexpected 1.12.2 release metadata') + throw new GradleException('Unexpected 26.3 release metadata') } } } @@ -510,7 +650,7 @@ tasks.register('verifyReleaseArtifacts') { ZipFile zip = new ZipFile(new File(libs, releaseFiles.get()[0])) try { Set names = zip.entries().collect { it.name } as Set - ['mcmod.info', 'META-INF/LICENSE', 'META-INF/LICENSE.spdx', 'META-INF/NOTICE', + ['META-INF/mods.toml', 'META-INF/LICENSE', 'META-INF/LICENSE.spdx', 'META-INF/NOTICE', 'zone/moddev/mc/skysterrainsmoother/SkysTerrainSmoother.class'].each { required -> if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}") } @@ -535,8 +675,8 @@ tasks.register('verifyReleaseArtifacts') { if (!entry.directory) { byte[] auditBytes = zip.getInputStream(entry).bytes if (entry.name.endsWith('.class') && - (((auditBytes[6] & 255) << 8) | (auditBytes[7] & 255)) != 52) { - throw new GradleException("Non-Java-8 production class: ${entry.name}") + (((auditBytes[6] & 255) << 8) | (auditBytes[7] & 255)) != 69) { + throw new GradleException("Non-Java-21 production class: ${entry.name}") } String content = new String(zip.getInputStream(entry).bytes, StandardCharsets.ISO_8859_1).toLowerCase(Locale.ROOT) @@ -603,6 +743,7 @@ tasks.register('writeReleaseChecksums') { ext.releaseFiles = releaseFiles ext.releaseJar = releaseJar apply from: 'gradle/release.gradle' +apply from: 'gradle/native-runtime-log.gradle' // Keep the old entry point disabled until publication is configured. @@ -615,6 +756,7 @@ idea { } eclipse { classpath { + defaultOutputDir = file('bin/default') downloadSources = true downloadJavadoc = true file.whenMerged { classpath -> @@ -689,14 +831,19 @@ tasks.register('isolateEclipseProductionRuns') { File launch = file(name) if (!launch.isFile()) throw new GradleException("ForgeGradle did not generate ${name}") String contents = launch.getText('UTF-8') - contents = contents.replace('${MC_VERSION}', project.minecraft_version.toString()) boolean production = name in ['runClient.launch', 'runServer.launch'] + if (production) { + String roots = "${mod_id}%%${projectDir}/bin/main" + contents = contents.replaceAll(/key="MOD_CLASSES" value="[^"]*"/) { + 'key="MOD_CLASSES" value="' + roots + '"' + } + } if (production && !contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { contents = contents.replace('', ' ' + System.lineSeparator() + '') } - ['--cache', '--metadata', '--to-srg', '--to-obf'].each { option -> + ['--cache', '--metadata'].each { option -> String prefix = option + ' ' int start = contents.indexOf(prefix) if (start < 0) throw new GradleException("${name} is missing ${option}") @@ -714,6 +861,7 @@ tasks.register('isolateEclipseProductionRuns') { } tasks.named('genEclipseRuns') { + dependsOn prepareContentDevelopmentRun finalizedBy configureEclipseBuildship, 'isolateEclipseProductionRuns' } @@ -739,7 +887,7 @@ tasks.register('verifyEclipseProductionClasspath') { if (overrides != expectedOverrides) { throw new GradleException('Eclipse Buildship retained an incorrect Building Pieces version override') } - File metadata = file('bin/main/mcmod.info') + File metadata = file('bin/main/META-INF/mods.toml') def eclipseClasspath = new groovy.xml.XmlSlurper().parse(file('.classpath')) def piecesLibraries = eclipseClasspath.classpathentry.findAll { it.@kind == 'lib' && it.@path.text().tokenize('/\\').last().startsWith('SkysBuildingPieces-') @@ -758,9 +906,23 @@ tasks.register('verifyEclipseProductionClasspath') { resourceFolders.count { it == 'build/resources/main' } != 1) { throw new GradleException('Eclipse must copy processed resources, not overwrite them with templates') } - def parsed = new JsonSlurper().parseText(metadata.getText('UTF-8')) - if (parsed[0].version != project.mod_version || parsed[0].mcversion != project.minecraft_version) { - throw new GradleException('Eclipse output contains incorrect mcmod.info') + String nativeMetadata = metadata.isFile() ? metadata.getText('UTF-8') : '' + if (!nativeMetadata.contains("version=\"${project.mod_version}\"") || + !nativeMetadata.contains("versionRange=\"${project.minecraft_version_range}\"")) { + throw new GradleException('Eclipse output contains incorrect mods.toml') + } + ['runClient.launch', 'runServer.launch', 'runTestClient.launch', 'runTestServer.launch'].each { name -> + String contents = file(name).getText('UTF-8') + if (contents.contains('${MC_VERSION}')) { + throw new GradleException("${name} retains ForgeGradle's unresolved MC_VERSION token") + } + if (!contents.contains('--main net.minecraftforge.bootstrap.ForgeBootstrap') || + !contents.contains("forge-${project.minecraft_version}-${project.forge_version}-metadata.zip")) { + throw new GradleException("${name} must use the native launcher and Minecraft ${project.minecraft_version}") + } + if (contents.contains('--to-srg') || contents.contains('--to-obf')) { + throw new GradleException("${name} requests remapping on an official-name runtime") + } } ['runClient.launch', 'runServer.launch'].each { name -> String contents = file(name).getText('UTF-8').replace('\\', '/') @@ -768,6 +930,15 @@ tasks.register('verifyEclipseProductionClasspath') { contents.contains('src/test') || contents.contains('bin/test') || contents.contains('junit-')) { throw new GradleException("${name} exposes test code or dependencies") } + def xml = new groovy.xml.XmlParser(false, false).parse(file(name)) + String modClasses = xml.mapAttribute.find { + it.@key == 'org.eclipse.debug.core.environmentVariables' + }.mapEntry.find { it.@key == 'MOD_CLASSES' }.@value + List roots = modClasses.split(java.util.regex.Pattern.quote(File.pathSeparator)).toList() + if (roots.size() != 1 || + roots[0].replace('\\', '/') != "${mod_id}%%${projectDir}/bin/main".replace('\\', '/')) { + throw new GradleException("${name} must have one merged production output in MOD_CLASSES") + } } } } @@ -777,31 +948,179 @@ tasks.named('check') { } def clientFresh = tasks.register('integrationClientFreshProcess', JavaExec) { - dependsOn prepareIntegrationTest - workingDir integrationRunDirectory + dependsOn prepareIntegrationClient + workingDir integrationClientDirectory systemProperty 'skysterrainsmoother.integrationPhase', 'client-fresh' - systemProperty 'log4j.configurationFile', file('src/integrationTest/resources/log4j2.xml').toURI().toString() + args '--accessToken', '0' configureFromForgeRun(delegate, 'runClient') doLast { - File marker=new File(integrationRunDirectory.get().asFile,'saves/terrain-smoother-world/skysterrainsmoother-integration.properties') + File marker=new File(integrationClientDirectory.get().asFile,'saves/terrain-smoother-world/skysterrainsmoother-integration.properties') if(!marker.isFile() || !marker.text.contains('fresh_complete=true')) throw new GradleException('Integrated fresh client did not complete') + project.services.get(org.gradle.process.ExecOperations).exec { + commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'), + 'tools/native_client_log.py', '--directory', integrationClientDirectory.get().asFile.absolutePath, + '--phase', 'client-fresh', '--offline-client' + } + copy { from new File(integrationClientDirectory.get().asFile, 'logs'); into new File(integrationClientDirectory.get().asFile, 'phase-logs/fresh') } } } tasks.register('integrationClientReloadProcess', JavaExec) { dependsOn clientFresh - workingDir integrationRunDirectory + workingDir integrationClientDirectory systemProperty 'skysterrainsmoother.integrationPhase', 'client-reload' - systemProperty 'log4j.configurationFile', file('src/integrationTest/resources/log4j2.xml').toURI().toString() + args '--accessToken', '0' configureFromForgeRun(delegate, 'runClient') doLast { - File marker=new File(integrationRunDirectory.get().asFile,'saves/terrain-smoother-world/skysterrainsmoother-integration.properties') + File marker=new File(integrationClientDirectory.get().asFile,'saves/terrain-smoother-world/skysterrainsmoother-integration.properties') if(!marker.isFile() || !marker.text.contains('reload_complete=true')) throw new GradleException('Integrated reload client did not complete') + project.services.get(org.gradle.process.ExecOperations).exec { + commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'), + 'tools/native_client_log.py', '--directory', integrationClientDirectory.get().asFile.absolutePath, + '--phase', 'client-reload', '--offline-client' + } + copy { from new File(integrationClientDirectory.get().asFile, 'logs'); into new File(integrationClientDirectory.get().asFile, 'phase-logs/reload') } } } ext.configureMineralogyVerificationRun = configureFromForgeRun ext.mineralogyProfileProbeId = 'skysterrainsmoother' -ext.mineralogyProfileAdditionalMods = files() +ext.mineralogyProfileAdditionalMods = files(grassSlabsDevelopmentJar, buildingPiecesDevelopmentJar) apply from: 'gradle/mineralogy-runs.gradle' apply from: 'gradle/recipe-advancements.gradle' + +// Keep early native checks separate while the full gameplay suite uses its own probe. +def nativeClasses = layout.buildDirectory.dir('native-bootstrap/classes') +def nativeCompile = tasks.register('compileNativeBootstrapProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/bootstrapTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.runtimeClasspath) + destinationDirectory = nativeClasses +} +def nativeJar = tasks.register('nativeBootstrapProbeJar', Jar) { + dependsOn nativeCompile + archiveFileName = 'terrain-native-checks.jar' + destinationDirectory = layout.buildDirectory.dir('native-bootstrap') + from nativeClasses + from 'src/bootstrapTest/resources' +} +def nativeDirectory = layout.buildDirectory.dir('native-bootstrap-run') +def nativePrepare = tasks.register('prepareNativeBootstrapTest') { + dependsOn nativeJar, verifyContentDependencies + doLast { + File target = nativeDirectory.get().asFile + if (target.canonicalFile != new File(buildDir, 'native-bootstrap-run').canonicalFile) + throw new GradleException('Native checks must stay inside build output') + if (new File(target, 'logs/latest.log').isFile()) + throw new GradleException('Archive the previous native runtime evidence before preparing another run') + target.mkdirs() + copy { from 'src/bootstrapTest/defaultconfigs'; into target } + copy { from nativeJar.flatMap { it.archiveFile }, grassSlabsDevelopmentJar, buildingPiecesDevelopmentJar; into new File(target, 'mods') } + new File(target, 'eula.txt').text = 'eula=true\n' + ['banned-players.json', 'banned-ips.json', 'ops.json', 'whitelist.json'].each { new File(target, it).text = '[]\n' } + new File(target, 'server.properties').text = 'level-name=world\nlevel-seed=4815162342\nonline-mode=false\nserver-ip=127.0.0.1\nserver-port=0\ngenerate-structures=false\nmax-tick-time=120000\n' + } +} +tasks.register('nativeBootstrapTest', JavaExec) { + dependsOn nativePrepare + workingDir nativeDirectory + configureFromForgeRun(delegate, 'runServer') + doLast { + File log = new File(nativeDirectory.get().asFile, 'logs/latest.log') + String result = log.isFile() ? readForgeRuntimeLog(log) : '' + if (!result.contains('TERRAIN_NATIVE_BOOTSTRAP_PASS') || !result.contains('Done (') || + result =~ /(?m)\[[^\r\n]*\/(?:ERROR|FATAL)\]/ || result.contains('[STDERR/') || + !fileTree(new File(nativeDirectory.get().asFile, 'crash-reports')).isEmpty()) + throw new GradleException('Native Terrain Smoother checks failed; inspect the full logs') + } +} + +tasks.register('verifyNativeLogPolicy', Exec) { + group = 'verification' + commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'), + '-m', 'unittest', 'discover', '-s', 'tools', '-p', 'test_native_*_log.py' +} +tasks.named('check') { dependsOn tasks.named('verifyNativeLogPolicy') } + +// Recipe advancements are already in the processed resource tree. A second +// Eclipse source entry would copy the same resources into bin/main twice. +eclipse.classpath.file.whenMerged { classpath -> + classpath.entries.removeAll { entry -> + entry.kind == 'src' && entry.path == 'build/generated/recipe-advancements' + } +} + +def familyForwardDirectory = layout.buildDirectory.dir('family-forward-run') +def prepareFamilyForward = tasks.register('prepareFamilyForwardFixture') { + dependsOn integrationTestModJar, verifyContentDependencies + doLast { + File target = familyForwardDirectory.get().asFile + if (new File(target, 'logs/latest.log').isFile()) { + throw new GradleException('Archive the previous forward-upgrade evidence before preparing another run') + } + File fixture = file('src/test/resources/fixtures/forward-1.12.2-family') + File archive = new File(fixture, 'world.zip') + String expected = new File(fixture, 'SHA256SUMS').getText('UTF-8').trim().split(/\s+/)[0] + String actual = MessageDigest.getInstance('SHA-256').digest(Files.readAllBytes(archive.toPath())) + .encodeHex().toString().toUpperCase(Locale.ROOT) + if (actual != expected) throw new GradleException('The sealed source world changed') + copy { from zipTree(archive); into target } + copy { from archive; into target; rename { 'source-world.zip' } } + copy { from new File(fixture, 'source-family-fixture.json'); into target } + String sourceManifestHash = MessageDigest.getInstance('SHA-256') + .digest(new File(fixture, 'source-family-fixture.json').bytes) + .encodeHex().toString().toUpperCase(Locale.ROOT) + new File(target, 'source-inputs.json').setText(groovy.json.JsonOutput.toJson([ + world_sha256: expected, mode: 'server', manifest_sha256: sourceManifestHash + ]), 'UTF-8') + copy { from 'src/bootstrapTest/defaultconfigs'; into target } + copy { + from integrationTestModJar.flatMap { it.archiveFile }, grassSlabsDevelopmentJar, buildingPiecesDevelopmentJar + into new File(target, 'mods') + } + new File(target, 'eula.txt').setText('eula=true\n', 'UTF-8') + ['banned-players.json', 'banned-ips.json', 'ops.json', 'whitelist.json'].each { name -> + new File(target, name).setText('[]\n', 'UTF-8') + } + new File(target, 'server.properties').setText('level-name=world\nonline-mode=false\n' + + 'server-ip=127.0.0.1\nserver-port=0\nview-distance=6\nmax-tick-time=120000\n', 'UTF-8') + } +} +def forwardFresh = tasks.register('integrationForwardFreshProcess', JavaExec) { + dependsOn prepareFamilyForward + workingDir familyForwardDirectory + systemProperty 'skysterrainsmoother.forwardFixture', 'true' + systemProperty 'skysterrainsmoother.integrationPhase', 'fresh' + configureFromForgeRun(delegate, 'runServer') +} +def forwardReload = tasks.register('integrationForwardReloadProcess', JavaExec) { + dependsOn forwardFresh + workingDir familyForwardDirectory + systemProperty 'skysterrainsmoother.forwardFixture', 'true' + systemProperty 'skysterrainsmoother.integrationPhase', 'reload' + configureFromForgeRun(delegate, 'runServer') +} +[forwardFresh: 'fresh', forwardReload: 'reload'].each { taskName, phase -> + def task = taskName == 'forwardFresh' ? forwardFresh : forwardReload + task.configure { + doLast { + File target = familyForwardDirectory.get().asFile + File marker = new File(target, 'world/family-forward-' + phase + '.properties') + if (!marker.isFile() || !marker.getText('UTF-8').contains('complete=true')) { + throw new GradleException('Forward upgrade checks did not complete: ' + phase) + } + project.services.get(org.gradle.process.ExecOperations).exec { + commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'), + 'tools/native_client_log.py', '--server', '--directory', target.absolutePath, '--phase', phase + } + copy { from new File(target, 'logs'); into new File(target, 'phase-logs/' + phase) } + } + } +} +tasks.register('integrationForwardTest') { + group = 'verification' + description = 'Upgrades and reloads a sealed native 1.12 family world.' + dependsOn forwardReload +} + +tasks.named('check') { dependsOn tasks.named('integrationForwardTest') } diff --git a/ci-fixtures/tools/LICENSE-MAVENIZER.txt b/ci-fixtures/tools/LICENSE-MAVENIZER.txt new file mode 100644 index 0000000..8000a6f --- /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 0000000..6607f17 --- /dev/null +++ b/ci-fixtures/tools/README.md @@ -0,0 +1,47 @@ +# 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 `26.3-66.0.2` 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. + +Forge `26.3-66.0.2` compiles without source compatibility edits. Its explicit +zero-rule entry proves that result is intentional and target-qualified rather +than an accidental fall-through. A marker beside Mavenizer's output records +the target and manifest SHA-256. Reprocessing the same source is idempotent. + +The derivative also propagates Gradle offline mode when the build sets +`ORESPAWN_MAVENIZER_OFFLINE=true`. Mavenizer, IronAgeFurniture and Minecraft 26.3 all +run and compile with Java 25. + +## 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 IronAgeFurniture'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 0000000..d74dabc 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 0000000..7d2ad63 --- /dev/null +++ b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch @@ -0,0 +1,381 @@ +From debd1358466af4d21ae25079ade85486fb267762 Mon Sep 17 00:00:00 2001 +From: JohnBraham +Date: Sat, 19 Sep 2026 16:12:24 +0100 +Subject: [PATCH] Qualify Mavenizer for Forge 26.3 + +--- + 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 | 247 ++++++++++++++++++ + .../minecraft-source-compatibility.json | 9 + + 6 files changed, 279 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..4d72d34 +--- /dev/null ++++ b/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java +@@ -0,0 +1,247 @@ ++/* ++ * 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); ++ if (target.expectedApplications == 0) { ++ writeMarker(outputJar, artifact, manifestHash, target.rules); ++ LOGGER.info("OreSpawn Mavenizer compatibility: applied 0 rule(s) for " + artifact ++ + " (explicit no-op) manifest SHA-256 " + manifestHash); ++ return; ++ } ++ 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..3451901 +--- /dev/null ++++ b/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json +@@ -0,0 +1,9 @@ ++{ ++ "schema": 1, ++ "targets": { ++ "net.minecraftforge:forge:26.3-66.0.2": { ++ "expectedApplications": 0, ++ "rules": [] ++ } ++ } ++} +-- +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 0000000..f5105e6 --- /dev/null +++ b/ci-fixtures/tools/minecraft-source-compatibility.json @@ -0,0 +1,9 @@ +{ + "schema": 1, + "targets": { + "net.minecraftforge:forge:26.3-66.0.2": { + "expectedApplications": 0, + "rules": [] + } + } +} diff --git a/docs/API.md b/docs/API.md index f759c24..7663d79 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,20 +1,23 @@ # Add-on API, version 1 Use `zone.moddev.mc.skysterrainsmoother.api.TerrainSmoothingApi` during your -mod's initialization or post-initialization. Declare ordering after -`skysterrainsmoother`. Registration closes at load completion. +mod's common/client setup or IMC callbacks. Declare ordering after +`skysterrainsmoother`. Registration closes at load completion. Forge can run setup callbacks in +parallel; the catalogue serializes registration and publishes immutable lookups. +Biome and protection callbacks must also be safe to call from generation threads. Create an immutable `TerrainMaterial` with a namespaced logical identity, a set of exact full-block source states and a bottom slab state. Optionally supply arrays of four bottom steps and four bottom corners. Array order is north, east, south, west; corners use northwest, northeast, southeast, southwest. Steps occupy one quarter of a block's volume and corners one eighth. -Every output must be half a block tall, non-tile, and match that footprint. +Every output must be half a block tall, dry, non-tile, and fill that footprint. +Sources and converted supports must be dry full blocks without tile entities. ```java TerrainSmoothingApi.registerMaterial(new TerrainMaterial( - new ResourceLocation("example", "natural_rock"), - Collections.singleton(naturalRock.getDefaultState()), + Identifier.fromNamespaceAndPath("example", "natural_rock"), + Collections.singleton(naturalRock.defaultBlockState()), bottomSlab, null, null, false, null)); ``` diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 3a6a6e2..3a5187f 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -1,14 +1,17 @@ # Building from source -Use the Gradle 9.6.1 wrapper with Java 17 and Temurin 8.0.502+7 for production. -Set `STS_JAVA8_HOME` to the Java 8 installation. This branch targets -Minecraft 1.12.2 with ForgeGradle 7.0.34. +Use the Gradle 9.7.1 wrapper with Java 25 and Temurin 25.0.3+9 for production. +Set `STS_JAVA25_HOME` to the Java 25 installation. This branch targets +Minecraft 26.3 with ForgeGradle 7.0.34. +ForgeGradle's Mavenizer also uses Java 25 to prepare the Forge dependency. +The mod itself compiles and runs on Java 25. Build the exact Grass Slabs and Building Pieces source commits listed in -`gradle/content-dependencies.properties` with their `jar` tasks first. +`gradle/content-dependencies.properties` with their `assemble` tasks first. The build checks both their source commits +and main/development jar checksums. -The source lock records released Grass Slabs 1.1.2.112021 and Building Pieces -0.3.8.112021. +The source lock records locally qualified Grass Slabs 1.2.0.2603001 and Building Pieces +0.3.9.2603001. The default layout uses sibling checkouts. For another layout, pass `-PgrassSlabsDirectory=` and @@ -19,9 +22,7 @@ Then run: gradlew check build javadoc verifyReleaseArtifacts writeReleaseChecksums verifyEclipseProductionClasspath ``` -Development dependencies use deobfuscated jars. Packaged games use the normal -main jars; dependency classes and assets are not bundled. CI builds the exact -pinned commits rather than moving branches. +Development and packaged games use the same jars. The build keeps dependency classes and assets out of the mod jar. CI builds the pinned commits. A local-only `buildingPiecesCoreVersion` override can test another core candidate. It requires an explicit core directory and is rejected in CI. @@ -29,8 +30,10 @@ Eclipse's ignored Buildship preferences retain the selected checkouts. A version override is saved only when explicitly supplied; normal generation clears any older override. Refresh the Gradle project in Eclipse after generating the runs. -Use `genEclipseRuns eclipse` to prepare the Java 8 IDE setup. Ordinary launches -exclude unit tests and build-only runtime probes. See [the release guide](RELEASING.md) +Use `genEclipseRuns eclipse` to prepare the Java 25 IDE setup. Ordinary launches +use the two pinned content jars and exclude unit tests and build-only runtime probes. +Generation prepares those jars in the development mods folder without clearing +its configuration or worlds. See [the release guide](RELEASING.md) for publication. ## Testing with Mineralogy @@ -45,13 +48,13 @@ its OreSpawn dependency. Ordinary client and server profiles keep their existing mod sets and worlds. Runtime probes and unit tests are excluded from these manual profiles. -The expected release jars and their checksums are listed in +The expected jars and their checksums are listed in `gradle/mineralogy-runtime.properties`. The default paths use sibling checkouts; for a different layout, pass `-PmineralogyRuntimeJar=` and `-PorespawnRuntimeJar=` when generating the profiles. Missing optional jars do not prevent an ordinary build; `prepareMineralogyDevelopmentRun` explains what is needed before the combined profile can run. The jars are copied, not -modified. Their access rules are translated for the mapped development game. +modified. Development and packaged runs use the same jars. If a sibling build has moved on, keep the qualified jars under `.gradle/qualification-dependencies/` with their original filenames. @@ -72,8 +75,25 @@ resources there. Neither task opens the manual profile's saved worlds. The build generates unlock advancements from `src/main/recipe-book/recipes.json`. The runtime checks compare those entries with the registered recipes and exercise inventory discovery, crafting-table discovery and recipe-book persistence. -Optional catalogue entries load only when their recipe exists. +All four sand recipes retain their original IDs. -Recipe unlocks have no shared advancement parent. Minecraft 1.12 walks that -visibility tree recursively when loading completed progress, which can overflow -with a large catalogue. The inventory and crafting unlock criteria are unchanged. +Recipe unlocks have no shared advancement parent. This avoids a long recursive +visibility chain when loading completed progress. Inventory and crafting-table +discovery keep the same recipe-book behaviour. + + +`integrationForwardTest` upgrades a sealed 1.12.2 save and reloads the result. +It checks placed pieces, chest stacks, dropped items, an item frame and the saved +player's main, ender and nested inventories. The source archive stays unchanged. +Archive a previous disposable run before starting that task again; its logs and +world are kept for inspection. + +The packaged checks also use a native 1.14.4 save in +`src/test/resources/fixtures/forward-1.14.4-family`. It retains the same building +and inventory coverage, with both sand colours, both slab halves and waterlogged +sand states saved by the preceding version. Its source artifacts and files are +checksum locked; checks always run on extracted copies. + +The packaged upgrade checks also cover a saved and reloaded 1.15.2 world in +`src/test/resources/fixtures/forward-1.15.2-family`. Its fixture records the exact +preceding jars and preserves all eight sand colour, half and water states. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b8c8e2f..d3c6521 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,7 +4,7 @@ Open **Mods**, select **Sky's Terrain Smoother** and choose **Config**. You can turn smoothing on or off, choose level 1–3 and enable or disable Mineralogy support. Save with **Done**, then restart Minecraft to apply your choices. -You can also edit `config/skysterrainsmoother.cfg` while the game or server is +You can also edit `config/skysterrainsmoother-common.toml` while the game or server is stopped. On a dedicated server, edit the server's file and restart it; the client's Config screen does not change a remote server. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5337f8b..07c107d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -4,8 +4,7 @@ Pushes and pull requests run the build, tests, artifact audit, CodeQL and Gradle wrapper checks. CI uploads the main, sources and Javadoc jars with their checksums. Tags only validate a candidate; they never publish it. -This mod publishes to CurseForge project 1768953. Publication is enabled only -in MMD; a fork build never uploads a release. +This mod publishes to CurseForge project 1768953. Only MMD can publish it. The MMD release workflow uses the organisation's `CURSEFORGE_TOKEN`, `MAVEN_UPLOAD_URL`, `MAVEN_UPLOAD_USERNAME` and `MAVEN_UPLOAD_PASSWORD`. diff --git a/docs/WORLD-UPGRADES.md b/docs/WORLD-UPGRADES.md index 47d9acf..5d2301e 100644 --- a/docs/WORLD-UPGRADES.md +++ b/docs/WORLD-UPGRADES.md @@ -16,3 +16,48 @@ their terrain can be smoothed. Turning smoothing off does not remove pieces already placed. They remain ordinary blocks owned by the relevant content mod. + +## Upgrading from Minecraft 26.2, 26.1.2, 1.21.11, 1.21.1, 1.20.6, 1.20.1 or 1.19.4 + +Update Grass Slabs, Building Pieces and Terrain Smoother together for +Minecraft 26.3. Keep your configuration and world data so your smoothing +settings and migration history carry over. Sand slabs keep their colour, +orientation and waterlogging. Explore new chunks to see the smoothing. + +## Upgrading from Minecraft 1.18.2 + +Update Terrain Smoother, Grass Slabs and Building Pieces together. Keep your +configuration and world data when you move the save. Sand slabs retain their +colour, slab half and waterlogging. Smoothing applies to newly generated chunks. + +## Upgrading from Minecraft 1.17.1 + +Update the three mods together and keep your configuration and world data. +Saved pieces, inventories and migration history are retained. The taller world +range also applies to newly generated cave floors; existing chunks stay unchanged. + +## Upgrading from Minecraft 1.16.5 + +Update Grass Slabs, Building Pieces and Terrain Smoother together. Keep the +configuration and world data files to retain your choices and migration history. +Saved sand slabs keep their colour, upper or lower half, water and item data. +Existing chunks stay unchanged; smoothing still applies only to new terrain. + +## Upgrading from Minecraft 1.14.4 + +Update Grass Slabs, Building Pieces and Terrain Smoother together. Existing +pieces keep their named states, including sand colour, slab halves and water. +Keep your configuration files to retain the smoothing level and other choices. +Saved chunks stay unchanged; smoothing still applies only to new terrain. + +## Upgrading from Minecraft 1.12.2 + +Use a backup and keep both required content mods installed. Sand slabs keep their +colour and upper/lower placement. Red sand now has its own block and item ID, +following Minecraft's flattened block format; saved red sand slabs and inventory +stacks are converted automatically, including unopened chunks. Custom item data +and stack counts are kept. An unsupported saved sand value stops the upgrade +with a report rather than replacing it with ordinary sand. + +This conversion does not smooth old terrain. Existing pieces remain in place, +and the saved-chunk index excludes previously generated chunks from new smoothing. diff --git a/gradle.properties b/gradle.properties index 8f9e378..650777b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,23 +4,29 @@ org.gradle.configuration-cache=false org.gradle.caching=true org.gradle.parallel=false net.minecraftforge.gradle.merge-source-sets=false -minecraft_version=1.12.2 -minecraft_version_range=[1.12.2] -forge_version=14.23.5.2859 -mapping_channel=stable -mapping_version=39-1.12 +minecraft_version=26.3 +minecraft_version_range=[26.3,26.4,) +forge_version=66.0.2 +forge_version_range=[66,) +loader_version_range=[66,) +mapping_channel=official +mapping_version=26.3 +mcp_version=20260918.230105 loader_name=forge loader_code=1 -java_version=8 -java_toolchain_version=8.0.502+7 -gradle_java_version=17 +java_version=25 +java_toolchain_version=25.0.3+9 +gradle_java_version=25 +gradle_java_toolchain_version=25.0.3+9 mod_id=skysterrainsmoother release_repository=MinecraftModDevelopmentMods/SkysTerrainSmoother release_enabled=true curseforge_project_id=1768953 mod_name=Sky's Terrain Smoother mod_license=LGPL-2.1-only -mod_version=0.1.0.112021 +mod_version=0.1.0.2603001 mod_group=zone.moddev.mc.skysterrainsmoother mod_authors=SkyBlade1978 mod_description=Smooths new Overworld surfaces with existing slabs, steps and corners. Optional Mineralogy support. + +java_setup_version=25.0.3+9.0.LTS diff --git a/gradle/content-dependencies.properties b/gradle/content-dependencies.properties index 3cac94c..f4cc065 100644 --- a/gradle/content-dependencies.properties +++ b/gradle/content-dependencies.properties @@ -1,6 +1,13 @@ +# Exact local content candidates. Publication remains separate. grass.repository=MinecraftModDevelopmentMods/SkysGrassSlabs -grass.commit=b2636d8a67efa3f257a8a69293ce513e979010fd -grass.version=1.1.2.112021 +grass.kind=local-candidate +grass.commit=014f8d16f0a5ef83c4cc236d37590afac56a6087 +grass.version=1.2.0.2603001 +grass.sha256=20AE5DC12C97E8DC1D348282BE50697909E98BBCE8C3BD40E4CF910E7D85F7D9 +grass.development.sha256=20AE5DC12C97E8DC1D348282BE50697909E98BBCE8C3BD40E4CF910E7D85F7D9 pieces.repository=MinecraftModDevelopmentMods/SkysBuildingPieces -pieces.commit=af3ce9b73d215a2f9e27a11324da56a752e4edac -pieces.version=0.3.8.112021 +pieces.kind=local-candidate +pieces.commit=c4df80b09d366b293cd3d06b5b92b376b34fafe3 +pieces.version=0.3.9.2603001 +pieces.sha256=A21755D614D3953D08CE1545153800DA1722A63EEC330BC868C8C1A7DF76D298 +pieces.development.sha256=A21755D614D3953D08CE1545153800DA1722A63EEC330BC868C8C1A7DF76D298 diff --git a/gradle/mineralogy-runs.gradle b/gradle/mineralogy-runs.gradle index ede971a..7133c82 100644 --- a/gradle/mineralogy-runs.gradle +++ b/gradle/mineralogy-runs.gradle @@ -5,7 +5,7 @@ import java.nio.file.Files def runtimeLock = new Properties() file('gradle/mineralogy-runtime.properties').withInputStream { runtimeLock.load(it) } def runtimeSources = ['mineralogy', 'orespawn'].collectEntries { mod -> - File siblingJar = file(runtimeLock.getProperty(mod + '.path')) + File siblingJar = file(runtimeLock.getProperty(mod + '.development.path')) File retainedJar = file('.gradle/qualification-dependencies/' + siblingJar.name) // Sibling builds can change during other work. A retained copy still has to // pass the same checksum check before it can enter a development profile. @@ -18,54 +18,25 @@ def gameDirectory = layout.projectDirectory.dir( def configureVerification = project.ext.configureMineralogyVerificationRun -// Legacy release jars declare SRG access rules. The mapped development game needs -// their MCP names before launch; the original optional jars remain untouched. -configurations { mineralogyDevelopmentMappings } -repositories { - maven { - name = 'MineralogyDevelopmentMappings' - url = 'https://maven.minecraftforge.net/' - metadataSources { artifact() } - content { includeModule('de.oceanlabs.mcp', 'mcp_stable') } - } -} -dependencies { - mineralogyDevelopmentMappings "de.oceanlabs.mcp:mcp_stable:${project.mapping_version}@zip" -} -if (runtimeSources.values().every { it.isFile() }) { - Map mappedMembers = [:] - new java.util.zip.ZipFile(configurations.mineralogyDevelopmentMappings.singleFile).withCloseable { mappings -> - ['fields.csv', 'methods.csv'].each { name -> - mappings.getInputStream(mappings.getEntry(name)).withCloseable { input -> - input.getText('UTF-8').readLines().drop(1).each { row -> - String[] columns = row.split(',', 3) - mappedMembers[columns[0]] = columns[1] - } - } - } - } - List rules = [] - runtimeSources.each { mod, source -> - new java.util.jar.JarFile(source).withCloseable { archive -> - String declared = archive.manifest?.mainAttributes?.getValue('FMLAT') - declared?.tokenize()?.each { name -> - def entry = archive.getJarEntry('META-INF/' + name) - if (entry == null) throw new GradleException("Missing ${mod} access rules: ${name}") - archive.getInputStream(entry).withCloseable { input -> - String mapped = input.getText('UTF-8').replaceAll(/\b(?:func|field)_\d+_[A-Za-z_]+\b/) { member -> - if (!mappedMembers.containsKey(member)) throw new GradleException("Unmapped optional access rule: ${member}") - mappedMembers[member] - } - rules.add(mapped) - } +// Exact mapped reference jars already carry the target's native access rules. +// Leave their matching production artifacts unchanged for packaged checks. +ext.nativeOptionalDevelopmentJars = runtimeSources +ext.verifyNativeOptionalRuntime = tasks.register('verifyNativeOptionalRuntime') { + group = 'verification' + doLast { + ['mineralogy', 'orespawn'].each { mod -> + [[file(runtimeLock.getProperty(mod + '.path')), runtimeLock.getProperty(mod + '.sha256')], + [runtimeSources[mod], runtimeLock.getProperty(mod + '.development.sha256')]].each { pair -> + File source = pair[0] + if (!source.isFile()) throw new GradleException('Missing optional reference jar: ' + source) + String actual = MessageDigest.getInstance('SHA-256').digest(Files.readAllBytes(source.toPath())) + .encodeHex().toString().toUpperCase(Locale.ROOT) + if (actual != pair[1]) throw new GradleException('Optional reference hash changed: ' + source.name) } } } - File accessRules = file('.gradle/mineralogy-runtime/access-transformers.cfg') - accessRules.parentFile.mkdirs() - accessRules.setText(rules.join('\n') + '\n', 'UTF-8') - minecraft.accessTransformers.from(accessRules) } + def developmentLogging = file('gradle/development-log4j2.xml').toURI().toString() @@ -74,7 +45,7 @@ def installRuntimeMods = { if (!source.isFile()) { throw new GradleException("Missing optional ${mod} jar: ${source}. Set -P${mod}RuntimeJar=.") } - String expected = runtimeLock.getProperty(mod + '.sha256') + String expected = runtimeLock.getProperty(mod + '.development.sha256') String actual = MessageDigest.getInstance('SHA-256').digest(Files.readAllBytes(source.toPath())) .encodeHex().toString() if (!(expected ==~ /(?i)[a-f0-9]{64}/) || !actual.equalsIgnoreCase(expected)) { @@ -205,7 +176,11 @@ def prepareMineralogyIntegrationTest = tasks.register('prepareMineralogyIntegrat if (directory.canonicalFile != expected.canonicalFile) { throw new GradleException('Refusing to clear a Mineralogy verification directory outside the build output') } - project.delete(directory) + if (new File(directory, 'logs/latest.log').isFile()) { + throw new GradleException('Archive the previous optional runtime evidence before preparing another run') + } + directory.mkdirs() + copy { from 'src/bootstrapTest/defaultconfigs'; into directory } copy { from new File(gameDirectory.asFile, 'mods') from tasks.named('integrationTestModJar', Jar).flatMap { it.archiveFile } @@ -226,65 +201,91 @@ generate-structures=false spawn-animals=false spawn-monsters=false spawn-protection=0 +view-distance=6 max-tick-time=120000 '''.replace('level-type=default', probeId == 'skysterrainsmootherbop' ? 'level-type=BIOMESOP' : 'level-type=default'), 'UTF-8') } } +def auditOptionalPhase = { File directory, String phase, boolean server -> + File marker = new File(directory, (server ? 'world/' : 'saves/terrain-smoother-world/') + + probeId + '-integration.properties') + Properties evidence = new Properties() + if (!marker.isFile()) throw new GradleException('Optional runtime marker is missing: ' + phase) + marker.withInputStream { evidence.load(it) } + String completed = phase.endsWith('reload') ? 'reload_complete' : 'fresh_complete' + if (evidence.getProperty(completed) != 'true') { + throw new GradleException('Optional runtime did not complete: ' + phase) + } + List arguments = [providers.gradleProperty('pythonExecutable').getOrElse('python'), + 'tools/native_client_log.py', '--directory', directory.absolutePath, '--phase', phase] + if (server) arguments.add('--server') + project.services.get(org.gradle.process.ExecOperations).exec { commandLine arguments } + copy { from new File(directory, 'logs'); into new File(directory, 'phase-logs/' + phase) } +} + def fresh = tasks.register('integrationMineralogyFreshProcess', JavaExec) { group = 'verification' dependsOn prepareMineralogyIntegrationTest workingDir verificationDirectory systemProperty probeId + '.integrationPhase', 'fresh' - systemProperty 'log4j.configurationFile', developmentLogging systemProperty 'skysterrainsmoother.requireMineralogy', 'true' systemProperty 'skysterrainsmoother.testMineralogyShapes', 'false' configureVerification(delegate, 'runServer') + doLast { auditOptionalPhase(verificationDirectory.get().asFile, 'fresh', true) } } def reload = tasks.register('integrationMineralogyReloadProcess', JavaExec) { group = 'verification' dependsOn fresh workingDir verificationDirectory systemProperty probeId + '.integrationPhase', 'reload' - systemProperty 'log4j.configurationFile', developmentLogging systemProperty 'skysterrainsmoother.requireMineralogy', 'true' systemProperty 'skysterrainsmoother.testMineralogyShapes', 'false' configureVerification(delegate, 'runServer') + doLast { auditOptionalPhase(verificationDirectory.get().asFile, 'reload', true) } } tasks.register('integrationMineralogyTest') { group = 'verification' dependsOn reload +} + +def optionalClientDirectory = layout.buildDirectory.dir('mineralogy-client-run') +def prepareOptionalClient = tasks.register('prepareMineralogyIntegrationClient') { + dependsOn prepareMineralogyDevelopmentRun, tasks.named('integrationTestModJar') doLast { - File directory = verificationDirectory.get().asFile - File marker = new File(directory, "world/${probeId}-integration.properties") - Properties evidence = new Properties() - if (!marker.isFile()) throw new GradleException('Mineralogy integration marker is missing') - marker.withInputStream { evidence.load(it) } - if (evidence.getProperty('fresh_complete') != 'true' || evidence.getProperty('reload_complete') != 'true') { - throw new GradleException('Mineralogy fresh/reload verification did not complete') + File directory = optionalClientDirectory.get().asFile + if (new File(directory, 'logs/latest.log').isFile()) { + throw new GradleException('Archive the previous optional client evidence before preparing another run') } - if (!fileTree(new File(directory, 'crash-reports')).isEmpty()) { - throw new GradleException('Mineralogy verification produced a crash report') + copy { from 'src/bootstrapTest/defaultconfigs'; into directory } + copy { + from new File(gameDirectory.asFile, 'mods') + from tasks.named('integrationTestModJar', Jar).flatMap { it.archiveFile } + into new File(directory, 'mods') } + new File(directory, 'options.txt').setText( + 'fullscreen:false\nmaxFps:30\nrenderDistance:6\npauseOnLostFocus:false\n', 'UTF-8') } } - -tasks.register('integrationMineralogyClientProcess', JavaExec) { +def optionalClientFresh = tasks.register('integrationMineralogyClientProcess', JavaExec) { group = 'verification' - dependsOn prepareMineralogyIntegrationTest - workingDir verificationDirectory - systemProperty probeId + '.integrationPhase', 'client' - systemProperty 'log4j.configurationFile', developmentLogging + dependsOn prepareOptionalClient + workingDir optionalClientDirectory + systemProperty probeId + '.integrationPhase', 'client-fresh' systemProperty 'skysterrainsmoother.requireMineralogy', 'true' systemProperty 'skysterrainsmoother.testMineralogyShapes', 'false' configureVerification(delegate, 'runClient') - doLast { - String marker = probeId == 'skysterrainsmoother' ? - 'terrain-smoother-client-pass.txt' : 'bop-terrain-client-pass.txt' - if (!new File(verificationDirectory.get().asFile, marker).isFile()) { - throw new GradleException('The Mineralogy client did not complete resource verification') - } - } + doLast { auditOptionalPhase(optionalClientDirectory.get().asFile, 'client-fresh', false) } +} +tasks.register('integrationMineralogyClientReloadProcess', JavaExec) { + group = 'verification' + dependsOn optionalClientFresh + workingDir optionalClientDirectory + systemProperty probeId + '.integrationPhase', 'client-reload' + systemProperty 'skysterrainsmoother.requireMineralogy', 'true' + systemProperty 'skysterrainsmoother.testMineralogyShapes', 'false' + configureVerification(delegate, 'runClient') + doLast { auditOptionalPhase(optionalClientDirectory.get().asFile, 'client-reload', false) } } diff --git a/gradle/mineralogy-runtime.properties b/gradle/mineralogy-runtime.properties index 1b673d9..a287a45 100644 --- a/gradle/mineralogy-runtime.properties +++ b/gradle/mineralogy-runtime.properties @@ -1,5 +1,8 @@ -# Optional development runtime only. These jars are not bundled or required by the mod. -mineralogy.path=../../../MinecraftMineralogy/MinecraftMineralogy 112/MinecraftMineralogy/build/libs/Mineralogy-6.0.1.112021.jar -mineralogy.sha256=558AD3403486F3B5CF575494BDF0A36B9F5967EA8D58E94BDBC2C2B3B6AF5D22 -orespawn.path=../../../MinecraftMineralogy/OS112/OreSpawn/build/libs/OreSpawn-4.1.2.112021.jar -orespawn.sha256=50EE5C3453290AB5ECA6A87BD1FEC6F9A5A8DCC1D6F7632B9BFED9B991EA182D +orespawn.path=../../../MinecraftMineralogy/OS263/OreSpawn/build/libs/OreSpawn-4.1.0.2603001.jar +orespawn.sha256=A7FCDD626B9044091BFDB7277DA3169A5307EB5F97FCF88741A878401DCF1AEA +orespawn.development.path=../../../MinecraftMineralogy/OS263/OreSpawn/build/libs/OreSpawn-4.1.0.2603001.jar +orespawn.development.sha256=A7FCDD626B9044091BFDB7277DA3169A5307EB5F97FCF88741A878401DCF1AEA +mineralogy.path=../../../MinecraftMineralogy/MinecraftMineralogy 263/MinecraftMineralogy/build/libs/Mineralogy-6.1.4.2603001.jar +mineralogy.sha256=8C72389BC196F9732F32AFEBEF06E057D158DF4F4FA931059BE54480A230B453 +mineralogy.development.path=../../../MinecraftMineralogy/MinecraftMineralogy 263/MinecraftMineralogy/build/libs/Mineralogy-6.1.4.2603001.jar +mineralogy.development.sha256=8C72389BC196F9732F32AFEBEF06E057D158DF4F4FA931059BE54480A230B453 diff --git a/gradle/native-runtime-log.gradle b/gradle/native-runtime-log.gradle new file mode 100644 index 0000000..d6bdf33 --- /dev/null +++ b/gradle/native-runtime-log.gradle @@ -0,0 +1,14 @@ +// The empty Forge 66 server reproduces this Windows report without any mods. +// Keep it out of the build-only audit, and leave the raw logs in place. +ext.readForgeRuntimeLog = { File log -> + if (project.forge_version != '66.0.2' || + !System.getProperty('os.name').startsWith('Windows')) { + return log.getText('UTF-8') + } + + def python = providers.gradleProperty('pythonExecutable').getOrElse('python') + return providers.exec { + commandLine python, file('tools/native_windows_log.py'), log + environment 'PYTHONIOENCODING', 'utf-8' + }.standardOutput.asText.get() +} diff --git a/gradle/recipe-advancements.gradle b/gradle/recipe-advancements.gradle index 51bf68d..5ec9803 100644 --- a/gradle/recipe-advancements.gradle +++ b/gradle/recipe-advancements.gradle @@ -26,17 +26,12 @@ def generateRecipeAdvancements = tasks.register('generateRecipeAdvancements') { criteria[key] = [trigger: 'minecraft:inventory_changed', conditions: [items: [ingredient]]] requirements.add(key) } - criteria.has_the_recipe = [trigger: 'minecraft:recipe_unlocked', conditions: [recipe: recipe]] + criteria.has_the_recipe = [trigger: 'minecraft:recipe_unlocked', conditions: [recipes: recipe]] requirements.add('has_the_recipe') - // A shared parent makes 1.12 recurse through thousands of learned recipes on login. - // Independent hidden unlocks keep the same recipe-book behaviour without that recursion. + // Independent unlocks avoid a long recipe visibility chain on login. Map advancement = [rewards: [recipes: [recipe]], criteria: criteria, requirements: [requirements]] - if (project.mod_id != 'skysgrassslabs') { - // Optional catalogues and recovery recipes must not leave phantom unlocks. - advancement.conditions = [[type: 'skysbuildingpieces:recipe_exists', recipe: recipe]] - } - File target = new File(output, "assets/${owner}/advancements/recipes/${path}.json") + File target = new File(output, "data/${owner}/advancement/recipes/${path}.json") target.parentFile.mkdirs() target.setText(JsonOutput.prettyPrint(JsonOutput.toJson(advancement)) + '\n', 'UTF-8') } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 0d4a951..b1b8ef5 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 2c68b41..760c459 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip -distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 index cccdd3d..249efbb --- a/gradlew +++ b/gradlew @@ -1,78 +1,128 @@ -#!/usr/bin/env sh +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index f955316..a51ec4f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,84 +1,82 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/bootstrapTest/defaultconfigs/config/forge-client.toml b/src/bootstrapTest/defaultconfigs/config/forge-client.toml new file mode 100644 index 0000000..8195e39 --- /dev/null +++ b/src/bootstrapTest/defaultconfigs/config/forge-client.toml @@ -0,0 +1,5 @@ + +# Forge 61 client defaults used by the isolated launch checks. +[client] +showLoadWarnings = true +allowMipmapLowering = false diff --git a/src/bootstrapTest/defaultconfigs/defaultconfigs/forge-server.toml b/src/bootstrapTest/defaultconfigs/defaultconfigs/forge-server.toml new file mode 100644 index 0000000..b1938bc --- /dev/null +++ b/src/bootstrapTest/defaultconfigs/defaultconfigs/forge-server.toml @@ -0,0 +1,13 @@ + +#Server configuration settings +[server] + #Set this to true to remove any BlockEntity that throws an error in its update method instead of closing the server and reporting a crash log. BE WARNED THIS COULD SCREW UP EVERYTHING USE SPARINGLY WE ARE NOT RESPONSIBLE FOR DAMAGES. + removeErroringBlockEntities = false + #Set this to true to remove any Entity (Note: Does not include BlockEntities) that throws an error in its tick method instead of closing the server and reporting a crash log. BE WARNED THIS COULD SCREW UP EVERYTHING USE SPARINGLY WE ARE NOT RESPONSIBLE FOR DAMAGES. + removeErroringEntities = false + #Set this to true to check the entire entity's collision bounding box for ladders instead of just the block they are in. Causes noticeable differences in mechanics so default is vanilla behavior. Default: false. + fullBoundingBoxLadders = false + #The permission handler used by the server. Defaults to forge:default_handler if no such handler with that name is registered. + permissionHandler = "forge:default_handler" + #Set this to true to enable advertising the dedicated server to local LAN clients so that it shows up in the Multiplayer screen automatically. + advertiseDedicatedServerToLan = true diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysterrainsmoother/probe/NativeBootstrap.java b/src/bootstrapTest/java/zone/moddev/mc/skysterrainsmoother/probe/NativeBootstrap.java new file mode 100644 index 0000000..313ec10 --- /dev/null +++ b/src/bootstrapTest/java/zone/moddev/mc/skysterrainsmoother/probe/NativeBootstrap.java @@ -0,0 +1,134 @@ +package zone.moddev.mc.skysterrainsmoother.probe; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.Properties; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.entity.item.FallingBlockEntity; +import net.minecraft.world.level.material.Fluids; +import net.minecraft.world.item.ItemStack; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.block.state.properties.SlabType; +import net.minecraft.resources.Identifier; +import net.minecraft.core.BlockPos; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.level.Level; +import net.minecraft.server.level.ServerLevel; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; +import net.minecraftforge.event.server.ServerStartedEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; +import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; +import zone.moddev.mc.skysterrainsmoother.api.TerrainSmoothingApi; +import zone.moddev.mc.skysterrainsmoother.content.SandContent; +import zone.moddev.mc.skysterrainsmoother.content.SandLegacyRecovery; +import zone.moddev.mc.skysterrainsmoother.content.SandSlabBlock; +import zone.moddev.mc.skysterrainsmoother.internal.MaterialCatalogue; + +/** Small native lifecycle and falling-state checks, isolated from production launches. */ +@Mod("terrainbootstrap") +public final class NativeBootstrap { + private boolean setupComplete; + + public NativeBootstrap(FMLJavaModLoadingContext context) { + FMLCommonSetupEvent.getBus(context.getModBusGroup()).addListener(this::setup); + ServerStartedEvent.BUS.addListener(this::started); + } + + private void setup(FMLCommonSetupEvent event) { + require(TerrainSmoothingApi.hasMaterial(Identifier.parse("minecraft:stone")), + "Built-ins are available from a contributor's native setup callback"); + TerrainSmoothingApi.addBiomeRule((material, biome) -> true); + setupComplete = true; + } + + private void started(ServerStartedEvent event) { + ServerLevel world = event.getServer().getLevel(Level.OVERWORLD); + require(setupComplete && MaterialCatalogue.instance().frozen(), "Native setup completed and froze"); + require(GrassSlabsApi.hasSmoothingOwner(), "Grass generation ownership was claimed"); + boolean lateRejected = false; + try { + TerrainSmoothingApi.addBiomeRule((material, biome) -> true); + } catch (IllegalStateException expected) { + lateRejected = true; + } + require(lateRejected, "Late registration rejected"); + int checks = 3; + for (int metadata = 0; metadata < 4; metadata++) { + BlockState state = SandContent.legacyState(metadata); + Block block = (metadata & 2) == 0 ? SandContent.slab : SandContent.redSlab; + require(state.getBlock() == block && state.getValue(SandSlabBlock.TYPE) + == ((metadata & 1) == 0 ? SlabType.TOP : SlabType.BOTTOM), "Legacy sand orientation"); + require(!state.getValue(SandSlabBlock.WATERLOGGED), "Legacy sand begins dry"); + require(block.getStateDefinition().getPossibleStates().size() == 4, "No double sand state"); + CompoundTag oldStack = new CompoundTag(); + oldStack.putString("id", "skysterrainsmoother:sand_slab"); + oldStack.putByte("Count", (byte) 17); + oldStack.putShort("Damage", (short) metadata); + CompoundTag custom = new CompoundTag(); + custom.putString("Name", "Kept sand"); + oldStack.put("tag", custom); + SandLegacyRecovery.recoverItems(oldStack); + require(oldStack.getString("id").orElseThrow().equals(net.minecraftforge.registries.ForgeRegistries.BLOCKS.getKey(block).toString()) + && oldStack.getInt("Count").orElseThrow() == 17 && custom.equals(oldStack.getCompound("tag").orElseThrow()), + "Raw sand stack retains count and custom data"); + CompoundTag once = oldStack.copy(); + SandLegacyRecovery.recoverItems(oldStack); + require(once.equals(oldStack), "Sand item recovery is idempotent"); + checks += 5; + checks += falling(world, state, metadata, 220); + checks += falling(world, state, metadata, -56); + } + File root = event.getServer().getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toFile(); + Properties receipt = new Properties(); + receipt.setProperty("checks", Integer.toString(checks)); + receipt.setProperty("materials", Integer.toString(MaterialCatalogue.instance().size())); + try (FileOutputStream output = new FileOutputStream(new File(root, "native-terrain.properties"))) { + receipt.store(output, "Native Terrain Smoother checks"); + } catch (java.io.IOException failure) { + throw new IllegalStateException("Cannot write native check receipt", failure); + } + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("TERRAIN_NATIVE_BOOTSTRAP_PASS checks=" + checks); + event.getServer().halt(false); + } + + private static int falling(ServerLevel world, BlockState state, int metadata, int sourceY) { + BlockPos source = new BlockPos(80 + metadata * 4, sourceY, 80); + world.getChunk(source); + for (int height = sourceY - 5; height <= sourceY + 3; height++) { + world.setBlock(new BlockPos(source.getX(), height, source.getZ()), Blocks.AIR.defaultBlockState(), 2); + } + world.setBlock(source.below(5), Blocks.STONE.defaultBlockState(), 2); + world.setBlock(source, state, 2); + ((SandSlabBlock) state.getBlock()).tick(state, world, source, net.minecraft.util.RandomSource.create(1)); + java.util.List entities = world.getEntitiesOfClass(FallingBlockEntity.class, + new AABB(source).inflate(1)); + require(entities.size() == 1, "Native falling entity created"); + FallingBlockEntity falling = entities.get(0); + for (int tick = 0; tick < 100 && falling.isAlive(); tick++) { + falling.tick(); + } + require(!falling.isAlive() && world.getBlockState(source.below(4)).equals(state), + "Falling preserves native sand colour and orientation"); + world.setBlock(source, state.setValue(SandSlabBlock.WATERLOGGED, true), 2); + ((SandSlabBlock) state.getBlock()).tick(world.getBlockState(source), world, source, net.minecraft.util.RandomSource.create(1)); + entities = world.getEntitiesOfClass(FallingBlockEntity.class, new AABB(source).inflate(1)); + entities.removeIf(entity -> !entity.isAlive()); + require(entities.size() == 1, "Wet sand begins falling"); + FallingBlockEntity wet = entities.get(0); + require(!wet.getBlockState().getValue(SandSlabBlock.WATERLOGGED), "Falling sand carries no source water"); + wet.tick(); + require(world.getFluidState(source).getType() == Fluids.WATER, "Water remains at the source"); + wet.discard(); + return 5; + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/src/bootstrapTest/native-log4j2.xml b/src/bootstrapTest/native-log4j2.xml new file mode 100644 index 0000000..ce263e1 --- /dev/null +++ b/src/bootstrapTest/native-log4j2.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/bootstrapTest/resources/META-INF/mods.toml b/src/bootstrapTest/resources/META-INF/mods.toml new file mode 100644 index 0000000..eb6dafb --- /dev/null +++ b/src/bootstrapTest/resources/META-INF/mods.toml @@ -0,0 +1,14 @@ +modLoader="javafml" +loaderVersion="[40,)" +license="LGPL-2.1-only" +[[mods]] +modId="terrainbootstrap" +version="1.0" +displayName="Terrain Smoother Native Checks" +description="Build-only native lifecycle checks." +[[dependencies.terrainbootstrap]] +modId="skysterrainsmoother" +mandatory=true +versionRange="[0.1.0.120061,)" +ordering="AFTER" +side="BOTH" diff --git a/src/bootstrapTest/resources/pack.mcmeta b/src/bootstrapTest/resources/pack.mcmeta new file mode 100644 index 0000000..d76a3f5 --- /dev/null +++ b/src/bootstrapTest/resources/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "Build-only runtime checks", + "max_format": 121, + "min_format": 121 + } +} diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/CaveProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/CaveProbe.java index 86bf1cf..bd3e038 100644 --- a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/CaveProbe.java +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/CaveProbe.java @@ -1,18 +1,18 @@ package zone.moddev.mc.skysterrainsmoother.probe; import java.util.*; -import net.minecraft.block.state.IBlockState; -import net.minecraft.init.Blocks; -import net.minecraft.util.math.BlockPos; -import net.minecraft.world.WorldServer; -import net.minecraft.world.chunk.Chunk; -import net.minecraft.nbt.NBTTagCompound; -import net.minecraft.world.gen.structure.MapGenStructureData; -import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.fml.common.gameevent.TickEvent; -import net.minecraftforge.fml.relauncher.Side; -import net.minecraftforge.event.terraingen.DecorateBiomeEvent; -import net.minecraftforge.event.terraingen.PopulateChunkEvent; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.block.RedStoneOreBlock; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.fml.LogicalSide; import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; import zone.moddev.mc.skysterrainsmoother.api.TerrainMaterial; @@ -26,18 +26,18 @@ final class CaveProbe { private CaveProbe() { } - static int run(WorldServer world) { + static int run(ServerLevel world) { BlockPos floor = new BlockPos(3000, 70, 3000); - Chunk owner = world.getChunk(floor); + LevelChunk owner = world.getChunkAt(floor); clear(world, floor, 69, 255); fixture(world, floor, 6); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - RuntimeProbe.require(!world.isAirBlock(floor.up()), "cave floor below a roof receives a matching slab"); + RuntimeProbe.require(!world.isEmptyBlock(floor.above()), "cave floor below a roof receives a matching slab"); int checks = 1; - TerrainMaterial stone = MaterialCatalogue.instance().source(Blocks.STONE.getDefaultState()); + TerrainMaterial stone = MaterialCatalogue.instance().source(Blocks.STONE.defaultBlockState()); for (int level = 1; level <= 3; level++) { for (int cardinal = 0; cardinal < 16; cardinal++) { @@ -46,22 +46,22 @@ static int run(WorldServer world) { for (int directionIndex = 0; directionIndex < 4; directionIndex++) { if ((cardinal & (1 << directionIndex)) != 0) { - world.setBlockState(floor.add(DX[directionIndex], 1, DZ[directionIndex]), - Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor.offset(DX[directionIndex], 1, DZ[directionIndex]), + Blocks.STONE.defaultBlockState(), 2); } if ((diagonal & (1 << directionIndex)) != 0) { - world.setBlockState(floor.add(CX[directionIndex], 1, CZ[directionIndex]), - Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor.offset(CX[directionIndex], 1, CZ[directionIndex]), + Blocks.STONE.defaultBlockState(), 2); } } - IBlockState expected = MaterialCatalogue.instance().result(stone, + BlockState expected = MaterialCatalogue.instance().result(stone, Footprint.mask(cardinal, diagonal, level), level); SkysTerrainSmoother.engine.smooth(world, owner, level); RuntimeProbe.require( - world.getBlockState(floor.up()) - .equals(expected == null ? Blocks.AIR.getDefaultState() : expected), + world.getBlockState(floor.above()) + .equals(expected == null ? Blocks.AIR.defaultBlockState() : expected), "roofed footprint " + level + "/" + cardinal + "/" + diagonal); checks++; } @@ -70,58 +70,61 @@ static int run(WorldServer world) { // The same column can contain independent floors at multiple depths. fixture(world, floor, 6); - fixture(world, floor.up(16), 6); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); - world.setBlockState(floor.up(16).east().up(), Blocks.STONE.getDefaultState(), 2); + fixture(world, floor.above(16), 6); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); + world.setBlock(floor.above(16).east().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 3); - RuntimeProbe.require(!world.isAirBlock(floor.up()) && !world.isAirBlock(floor.up(17)), + RuntimeProbe.require(!world.isEmptyBlock(floor.above()) && !world.isEmptyBlock(floor.above(17)), "stacked cave floors are both smoothed"); checks++; for (int air = 1; air <= 4; air++) { fixture(world, floor, air + 1); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - RuntimeProbe.require(world.isAirBlock(floor.up()) == (air < 3), + RuntimeProbe.require(world.isEmptyBlock(floor.above()) == (air < 3), "low passage remains walkable: air height " + air); checks++; } - for (IBlockState obstacle : new IBlockState[] { Blocks.WATER.getDefaultState(), Blocks.LAVA.getDefaultState(), - Blocks.GLASS.getDefaultState(), Blocks.CHEST.getDefaultState() }) { + for (BlockState obstacle : new BlockState[] { Blocks.WATER.defaultBlockState(), Blocks.LAVA.defaultBlockState(), + Blocks.GLASS.defaultBlockState(), Blocks.CHEST.defaultBlockState() }) { for (int height = 1; height <= 3; height++) { fixture(world, floor, 6); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); - world.setBlockState(floor.up(height), obstacle, 2); + RuntimeProbe.require(world.isEmptyBlock(floor.above()) && world.isEmptyBlock(floor.above(2)) + && world.isEmptyBlock(floor.above(3)), "Cave fixture starts with clear headroom"); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); + world.setBlock(floor.above(height), obstacle, 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); RuntimeProbe.require( - height == 1 ? world.getBlockState(floor.up()).equals(obstacle) : world.isAirBlock(floor.up()), - "fluid, occupied target or blocked headroom is protected"); + height == 1 ? world.getBlockState(floor.above()).equals(obstacle) : world.isEmptyBlock(floor.above()), + "fluid, occupied target or blocked headroom is protected: " + obstacle + " / " + height + + " / actual=" + world.getBlockState(floor.above())); checks++; } } fixture(world, floor, 6); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); - world.setBlockState(floor.north().up(2), Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); + world.setBlock(floor.north().above(2), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - RuntimeProbe.require(world.isAirBlock(floor.up()), "solid cave wall is not a floor transition"); + RuntimeProbe.require(world.isEmptyBlock(floor.above()), "solid cave wall is not a floor transition"); checks++; // An open column must still see a higher floor hidden under its neighbour's roof. fixture(world, floor, 6); - world.setBlockToAir(floor.up(6)); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); + ProbeWorld.clear(world, floor.above(6)); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - RuntimeProbe.require(!world.isAirBlock(floor.up()), "cave entrance and outdoor floor join consistently"); + RuntimeProbe.require(!world.isEmptyBlock(floor.above()), "cave entrance and outdoor floor join consistently"); checks++; - for (IBlockState source : new IBlockState[] { Blocks.COAL_ORE.getDefaultState(), - Blocks.GRAVEL.getDefaultState(), Blocks.PLANKS.getDefaultState(), Blocks.MONSTER_EGG.getDefaultState(), - Blocks.CHEST.getDefaultState() }) { + for (BlockState source : new BlockState[] { Blocks.COAL_ORE.defaultBlockState(), + Blocks.GRAVEL.defaultBlockState(), Blocks.OAK_PLANKS.defaultBlockState(), Blocks.INFESTED_STONE.defaultBlockState(), + Blocks.CHEST.defaultBlockState() }) { fixture(world, floor, 6); - world.setBlockState(floor, source, 2); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor, source, 2); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - RuntimeProbe.require(world.isAirBlock(floor.up()) && world.getBlockState(floor).equals(source), + RuntimeProbe.require(world.isEmptyBlock(floor.above()) && world.getBlockState(floor).equals(source), "unregistered provider terrain, ores and tile sources remain intact"); checks++; } @@ -133,12 +136,12 @@ static int run(WorldServer world) { || material.mineralogy && !SkysTerrainSmoother.mineralogy) { continue; } - for (IBlockState source : material.sources) { + for (BlockState source : material.sources) { fixture(world, floor, 6); - world.setBlockState(floor, source, 2); - world.setBlockState(floor.north().up(), source, 2); + world.setBlock(floor, source, 2); + world.setBlock(floor.north().above(), source, 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - RuntimeProbe.require(world.getBlockState(floor.up()).equals(material.slab), + RuntimeProbe.require(world.getBlockState(floor.above()).equals(material.slab), "cave uses the exact registered material: " + material.id); RuntimeProbe .require( @@ -146,7 +149,7 @@ static int run(WorldServer world) { .equals(material.supportAfterPlacement == null ? source : material.supportAfterPlacement), "native support identity is preserved or dirtified"); - RuntimeProbe.require(world.getBlockState(floor.up(6)).getBlock() == Blocks.BRICK_BLOCK, + RuntimeProbe.require(world.getBlockState(floor.above(6)).getBlock() == Blocks.BRICKS, "cave ceiling remains intact"); checks += 3; @@ -157,87 +160,72 @@ static int run(WorldServer world) { } // Structure bounds and add-on vetoes apply to underground floors as well. - MapGenStructureData data = (MapGenStructureData) world.getPerWorldStorage() - .getOrLoadData(MapGenStructureData.class, "Mineshaft"); - - if (data == null) { - data = new MapGenStructureData("Mineshaft"); - world.getPerWorldStorage().setData("Mineshaft", data); - } - - String key = MapGenStructureData.formatChunkCoords(owner.x, owner.z); - NBTTagCompound previous = data.getTagCompound().hasKey(key) ? data.getTagCompound().getCompoundTag(key).copy() - : null; - + var previousStarts = new java.util.HashMap<>(owner.getAllStarts()); + BoundingBox protectedBounds = new BoundingBox(floor.getX() - 2, 69, floor.getZ() - 2, + floor.getX() + 2, 80, floor.getZ() + 2); + var mineshaft = world.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.STRUCTURE) + .getOrThrow(net.minecraft.world.level.levelgen.structure.BuiltinStructures.MINESHAFT).value(); + StructureStart protectedStart = protectedStart(owner, mineshaft, protectedBounds); + owner.setStartForStructure(mineshaft, protectedStart); try { - NBTTagCompound bounds = new NBTTagCompound(); - - bounds.setIntArray("BB", - new int[] { floor.getX() - 2, 69, floor.getZ() - 2, floor.getX() + 2, 80, floor.getZ() + 2 }); - data.writeInstance(bounds, owner.x, owner.z); fixture(world, floor, 6); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - RuntimeProbe.require(world.isAirBlock(floor.up()), "mineshaft structure bounds are protected underground"); + RuntimeProbe.require(world.isEmptyBlock(floor.above()), "Native mineshaft bounds protect underground floors"); checks++; } finally { - if (previous == null) { - data.getTagCompound().removeTag(key); - } else { - data.getTagCompound().setTag(key, previous); - } + owner.setAllStarts(previousStarts); } BlockPos veto = new BlockPos(floor.getX(), 240, floor.getZ()); fixture(world, veto, 6); - world.setBlockState(veto.north().up(), Blocks.STONE.getDefaultState(), 2); + world.setBlock(veto.north().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - RuntimeProbe.require(world.isAirBlock(veto.up()), "underground add-on area veto"); + RuntimeProbe.require(world.isEmptyBlock(veto.above()), "underground add-on area veto"); checks++; checks += lateDecoration(world, floor, owner); checks += oreDecoration(world, floor, owner); checks += manyFloors(world); - System.out.println("TERRAIN_SMOOTHER_CAVE_MATERIALS_PASS mineralogy=" + mineralogy); - System.out.println("TERRAIN_SMOOTHER_CAVE_PASS checks=" + checks); + checks += nativeHeightRange(world); + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("TERRAIN_SMOOTHER_CAVE_MATERIALS_PASS mineralogy=" + mineralogy); + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("TERRAIN_SMOOTHER_CAVE_PASS checks=" + checks); return checks; } - private static int lateDecoration(WorldServer world, BlockPos floor, Chunk owner) { + private static int lateDecoration(ServerLevel world, BlockPos floor, LevelChunk owner) { int checks = 0; for (int kind = 0; kind < 4; kind++) { fixture(world, floor, 6); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - MinecraftForge.EVENT_BUS.post( - new DecorateBiomeEvent.Post(world, new Random(1), new BlockPos(owner.x << 4, 0, owner.z << 4))); - RuntimeProbe.require(!world.isAirBlock(floor.up()), + ProbeWorld.repair(world, new BlockPos(owner.getPos().x() << 4, 0, owner.getPos().z() << 4)); + RuntimeProbe.require(!world.isEmptyBlock(floor.above()), "decorated cave cap retained until all later generators finish"); - MinecraftForge.EVENT_BUS.post(new PopulateChunkEvent.Post(world.getChunkProvider().chunkGenerator, world, - new Random(1), owner.x, owner.z, false)); + ProbeWorld.queue(world, owner.getPos().x(), owner.getPos().z()); if (kind == 0) { - world.setBlockState(floor, Blocks.MONSTER_EGG.getDefaultState(), 2); + world.setBlock(floor, Blocks.INFESTED_STONE.defaultBlockState(), 2); } if (kind == 1) { - world.setBlockState(floor, Blocks.SAND.getDefaultState(), 2); + world.setBlock(floor, Blocks.SAND.defaultBlockState(), 2); } if (kind == 2) { - world.setBlockState(floor.up(2), Blocks.BRICK_BLOCK.getDefaultState(), 2); + world.setBlock(floor.above(2), Blocks.BRICKS.defaultBlockState(), 2); } if (kind == 3) { - world.setBlockState(floor, Blocks.SAND.getDefaultState(), 2); - world.setBlockState(floor.up(), Blocks.GLASS.getDefaultState(), 2); + world.setBlock(floor, Blocks.SAND.defaultBlockState(), 2); + world.setBlock(floor.above(), Blocks.GLASS.defaultBlockState(), 2); } - MinecraftForge.EVENT_BUS.post(new TickEvent.WorldTickEvent(Side.SERVER, TickEvent.Phase.END, world)); + TickEvent.LevelTickEvent.Post.BUS.post(new TickEvent.LevelTickEvent.Post(LogicalSide.SERVER, world, () -> true)); RuntimeProbe.require( kind == 1 - ? world.getBlockState(floor.up()) + ? world.getBlockState(floor.above()) .getBlock() == zone.moddev.mc.skysterrainsmoother.content.SandContent.slab - : kind == 3 ? world.getBlockState(floor.up()).getBlock() == Blocks.GLASS - : world.isAirBlock(floor.up()), + : kind == 3 ? world.getBlockState(floor.above()).getBlock() == Blocks.GLASS + : world.isEmptyBlock(floor.above()), "late infested stone, sand, low ceiling and provider-owned replacement are handled without excavation"); checks += 2; } @@ -245,27 +233,25 @@ private static int lateDecoration(WorldServer world, BlockPos floor, Chunk owner return checks; } - private static int oreDecoration(WorldServer world, BlockPos floor, Chunk owner) { + private static int oreDecoration(ServerLevel world, BlockPos floor, LevelChunk owner) { int checks = 0; - for (IBlockState ore : new IBlockState[] { Blocks.COAL_ORE.getDefaultState(), Blocks.IRON_ORE.getDefaultState(), - Blocks.REDSTONE_ORE.getDefaultState(), Blocks.LIT_REDSTONE_ORE.getDefaultState(), - RuntimeProbe.probeOre.getDefaultState() }) { + for (BlockState ore : new BlockState[] { Blocks.COAL_ORE.defaultBlockState(), Blocks.IRON_ORE.defaultBlockState(), + Blocks.REDSTONE_ORE.defaultBlockState(), Blocks.REDSTONE_ORE.defaultBlockState().setValue(RedStoneOreBlock.LIT, true), + RuntimeProbe.probeOre.defaultBlockState() }) { for (int level = 1; level <= 3; level++) { fixture(world, floor, 6); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, level); - IBlockState cap = world.getBlockState(floor.up()); + BlockState cap = world.getBlockState(floor.above()); RuntimeProbe.require(cap.getBlock() != Blocks.AIR, "ore regression starts with a generated piece"); - world.setBlockState(floor, ore, 2); - MinecraftForge.EVENT_BUS.post( - new DecorateBiomeEvent.Post(world, new Random(1), new BlockPos(owner.x << 4, 0, owner.z << 4))); - MinecraftForge.EVENT_BUS.post(new PopulateChunkEvent.Post(world.getChunkProvider().chunkGenerator, - world, new Random(1), owner.x, owner.z, false)); - MinecraftForge.EVENT_BUS.post(new TickEvent.WorldTickEvent(Side.SERVER, TickEvent.Phase.END, world)); - RuntimeProbe.require(world.getBlockState(floor.up()).equals(cap), + world.setBlock(floor, ore, 2); + ProbeWorld.repair(world, new BlockPos(owner.getPos().x() << 4, 0, owner.getPos().z() << 4)); + ProbeWorld.queue(world, owner.getPos().x(), owner.getPos().z()); + TickEvent.LevelTickEvent.Post.BUS.post(new TickEvent.LevelTickEvent.Post(LogicalSide.SERVER, world, () -> true)); + RuntimeProbe.require(world.getBlockState(floor.above()).equals(cap), "ore under a generated piece stays hidden: " + ore + " / level " + level); RuntimeProbe.require(world.getBlockState(floor).equals(ore), "ore support is never overwritten"); checks += 3; @@ -273,77 +259,137 @@ private static int oreDecoration(WorldServer world, BlockPos floor, Chunk owner) } fixture(world, floor, 6); - world.setBlockState(floor.north().up(), Blocks.STONE.getDefaultState(), 2); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - world.setBlockState(floor, Blocks.COAL_ORE.getDefaultState(), 2); - world.setBlockState(floor.up(2), Blocks.BRICK_BLOCK.getDefaultState(), 2); - MinecraftForge.EVENT_BUS.post(new TickEvent.WorldTickEvent(Side.SERVER, TickEvent.Phase.END, world)); - RuntimeProbe.require(world.isAirBlock(floor.up()) && world.getBlockState(floor).getBlock() == Blocks.COAL_ORE, + world.setBlock(floor, Blocks.COAL_ORE.defaultBlockState(), 2); + world.setBlock(floor.above(2), Blocks.BRICKS.defaultBlockState(), 2); + TickEvent.LevelTickEvent.Post.BUS.post(new TickEvent.LevelTickEvent.Post(LogicalSide.SERVER, world, () -> true)); + RuntimeProbe.require(world.isEmptyBlock(floor.above()) && world.getBlockState(floor).getBlock() == Blocks.COAL_ORE, "ore preservation does not override cave headroom protection"); checks++; fixture(world, floor, 6); - world.setBlockState(floor.north().up(), Blocks.COAL_ORE.getDefaultState(), 2); - world.setBlockState(floor.north().up(2), - MaterialCatalogue.instance().source(Blocks.STONE.getDefaultState()).slab, 2); + world.setBlock(floor.north().above(), Blocks.COAL_ORE.defaultBlockState(), 2); + world.setBlock(floor.north().above(2), + MaterialCatalogue.instance().source(Blocks.STONE.defaultBlockState()).slab, 2); SkysTerrainSmoother.engine.smooth(world, owner, 1); - RuntimeProbe.require(!world.isAirBlock(floor.up()), + RuntimeProbe.require(!world.isEmptyBlock(floor.above()), "neighbouring retained ore cap still represents a higher logical floor"); checks++; - System.out.println("TERRAIN_SMOOTHER_ORE_SUPPORT_PASS checks=" + checks); + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("TERRAIN_SMOOTHER_ORE_SUPPORT_PASS checks=" + checks); + return checks; + } + + private static StructureStart protectedStart(LevelChunk owner, + net.minecraft.world.level.levelgen.structure.Structure structure, BoundingBox bounds) { + net.minecraft.world.level.levelgen.structure.StructurePiece piece = + new net.minecraft.world.level.levelgen.structure.StructurePiece( + net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType.MINE_SHAFT_ROOM, + 0, bounds) { + @Override + protected void addAdditionalSaveData( + net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext context, + net.minecraft.nbt.CompoundTag tag) { + } + + @Override + public void postProcess(net.minecraft.world.level.WorldGenLevel world, + net.minecraft.world.level.StructureManager structures, + net.minecraft.world.level.chunk.ChunkGenerator generator, + net.minecraft.util.RandomSource random, BoundingBox clip, + net.minecraft.world.level.ChunkPos chunk, BlockPos position) { + } + }; + return new StructureStart(structure, + owner.getPos(), 0, + new net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer(java.util.List.of(piece))); + } + + private static int nativeHeightRange(ServerLevel world) { + RuntimeProbe.require(world.getMinY() == -64 && world.getMaxY() + 1 == 320, + "The native Overworld exposes the complete 1.18 height range"); + BlockPos centre = new BlockPos(3020, 0, 3020); + LevelChunk owner = world.getChunkAt(centre); + TerrainMaterial stone = MaterialCatalogue.instance().source(Blocks.STONE.defaultBlockState()); + int[] floors = { -56, -24, 48, 264, 312 }; + int checks = 0; + for (int level = 1; level <= 3; level++) { + clear(world, centre, world.getMinY(), world.getMaxY()); + for (int floorY : floors) { + BlockPos floor = centre.atY(floorY); + fixture(world, floor, 6); + world.setBlock(floor.north().above(), Blocks.STONE.defaultBlockState(), 2); + } + ProbeWorld.rebuildHeights(owner); + int loadedBefore = world.getChunkSource().getLoadedChunksCount(); + SkysTerrainSmoother.engine.smooth(world, owner, level); + BlockState expected = MaterialCatalogue.instance().result(stone, Footprint.mask(1, 0, level), level); + for (int floorY : floors) { + BlockPos floor = centre.atY(floorY); + RuntimeProbe.require(world.getBlockState(floor.above()).equals(expected), + "Matching cave transition at native height " + floorY + " / level " + level); + RuntimeProbe.require(world.getBlockState(floor.above(6)).is(Blocks.BRICKS), + "Height-range smoothing keeps the cave roof intact"); + checks++; + } + RuntimeProbe.require(world.getChunkSource().getLoadedChunksCount() == loadedBefore, + "Height-range smoothing does not load neighbouring chunks"); + } + SkysTerrainSmoother.logger.info("TERRAIN_SMOOTHER_HEIGHT_RANGE_PASS checks={} min=-64 max=320 levels=3", checks); return checks; } - private static int manyFloors(WorldServer world) { - Chunk owner = world.getChunk(200, 200); - net.minecraft.world.chunk.storage.ExtendedBlockStorage[] sections = owner.getBlockStorageArray(); + private static int manyFloors(ServerLevel world) { + LevelChunk owner = world.getChunk(200, 200); + net.minecraft.world.level.chunk.LevelChunkSection[] sections = owner.getSections(); - for (int section = 0; section < 16; section++) { - sections[section] = new net.minecraft.world.chunk.storage.ExtendedBlockStorage(section * 16, true); + for (int section = 0; section < sections.length; section++) { + sections[section] = ProbeWorld.emptySection(world); } for (int blockZ = 0; blockZ < 16; blockZ++) { for (int blockX = 0; blockX < 16; blockX++) { for (int blockY = 32; blockY < 232; blockY += 8) { int floorY = blockY + ((blockX + blockZ) & 1); - sections[floorY >> 4].set(blockX, floorY & 15, blockZ, Blocks.STONE.getDefaultState()); + sections[owner.getSectionIndex(floorY)].setBlockState(blockX, floorY & 15, blockZ, Blocks.STONE.defaultBlockState()); } - sections[15].set(blockX, 246 & 15, blockZ, Blocks.BRICK_BLOCK.getDefaultState()); - owner.getHeightMap()[blockX | blockZ << 4] = 247; + sections[owner.getSectionIndex(246)].setBlockState(blockX, 246 & 15, blockZ, Blocks.BRICKS.defaultBlockState()); + } } - int countBefore = world.getChunkProvider().getLoadedChunkCount(); + int countBefore = world.getChunkSource().getLoadedChunksCount(); + ProbeWorld.rebuildHeights(owner); int placed = SkysTerrainSmoother.engine.smooth(world, owner, 1); RuntimeProbe.require(placed > 256, "decision buffer covers multiple floors across all 256 columns"); - RuntimeProbe.require(world.getChunkProvider().getLoadedChunkCount() == countBefore, + RuntimeProbe.require(world.getChunkSource().getLoadedChunksCount() == countBefore, "cave scan never loads a halo chunk"); int second = SkysTerrainSmoother.engine.smooth(world, owner, 1); RuntimeProbe.require(second == 0, "repeated floor scan never stacks new caps"); - System.out.println("TERRAIN_SMOOTHER_CAVE_BUFFER_PASS placed=" + placed); + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("TERRAIN_SMOOTHER_CAVE_BUFFER_PASS placed=" + placed); return 3; } - private static void clear(WorldServer world, BlockPos floor, int minY, int maxY) { + private static void clear(ServerLevel world, BlockPos floor, int minY, int maxY) { for (int blockZ = -2; blockZ <= 2; blockZ++) { for (int blockX = -2; blockX <= 2; blockX++) { for (int blockY = minY; blockY <= maxY; blockY++) { - world.setBlockToAir(new BlockPos(floor.getX() + blockX, blockY, floor.getZ() + blockZ)); + ProbeWorld.clear(world, new BlockPos(floor.getX() + blockX, blockY, floor.getZ() + blockZ)); } } } } - private static void fixture(WorldServer world, BlockPos floor, int roofOffset) { + private static void fixture(ServerLevel world, BlockPos floor, int roofOffset) { clear(world, floor, floor.getY(), floor.getY() + 8); for (int blockZ = -2; blockZ <= 2; blockZ++) { for (int blockX = -2; blockX <= 2; blockX++) { - world.setBlockState(floor.add(blockX, 0, blockZ), Blocks.STONE.getDefaultState(), 2); - world.setBlockState(floor.add(blockX, roofOffset, blockZ), Blocks.BRICK_BLOCK.getDefaultState(), 2); + world.setBlock(floor.offset(blockX, 0, blockZ), Blocks.STONE.defaultBlockState(), 2); + world.setBlock(floor.offset(blockX, roofOffset, blockZ), Blocks.BRICKS.defaultBlockState(), 2); } } } diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ClientProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ClientProbe.java index 9656a95..13618e2 100644 --- a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ClientProbe.java +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ClientProbe.java @@ -2,105 +2,184 @@ import java.io.*; import net.minecraft.client.Minecraft; -import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; -import net.minecraftforge.fml.common.gameevent.TickEvent; +import net.minecraftforge.event.TickEvent; public final class ClientProbe extends ProbeProxy { private boolean ran; private boolean integrated; + private boolean confirmedWorld; + private boolean resourcesChecked; + private int settledWorldTicks; + private boolean requestedRecipes; + private volatile java.util.Set recipeDisplays; @Override public void init() { - net.minecraftforge.client.model.ModelLoader.setCustomModelResourceLocation( - net.minecraft.item.Item.getItemFromBlock(RuntimeProbe.probeOre), 0, - new net.minecraft.client.renderer.block.model.ModelResourceLocation("minecraft:stone", "inventory")); - net.minecraftforge.client.model.ModelLoader.setCustomStateMapper(RuntimeProbe.probeOre, - new net.minecraft.client.renderer.block.statemap.StateMapperBase() { - @Override - protected net.minecraft.client.renderer.block.model.ModelResourceLocation getModelResourceLocation( - net.minecraft.block.state.IBlockState state) { - return new net.minecraft.client.renderer.block.model.ModelResourceLocation("minecraft:stone", - "normal"); - } - }); - MinecraftForge.EVENT_BUS.register(this); + TickEvent.ClientTickEvent.Post.BUS.addListener(this::tick); } - @SubscribeEvent - public void tick(TickEvent.ClientTickEvent event) { - Minecraft minecraft = Minecraft.getMinecraft(); + public void tick(TickEvent.ClientTickEvent.Post event) { + Minecraft minecraft = Minecraft.getInstance(); + + org.lwjgl.sdl.SDLVideo.SDL_HideWindow(minecraft.getWindow().handle()); - if (event.phase != TickEvent.Phase.END) { - return; - } if (ran) { - if (integrated && ClientStatus.complete) { - System.out.println("TERRAIN_SMOOTHER_CLIENT_WORLD_PASS"); - minecraft.shutdown(); + if (integrated && !confirmedWorld) confirmDisposableWorld(minecraft); + if (integrated && ClientStatus.complete && minecraft.level != null && minecraft.player != null + && minecraft.getConnection() != null && minecraft.getSingleplayerServer() != null) { + if (!requestedRecipes) { + requestedRecipes = true; + var server = minecraft.getSingleplayerServer(); + server.execute(() -> recipeDisplays = RecipePreviewModels.unlock(server)); + } + if (recipeDisplays == null || !RecipePreviewModels.ready(minecraft, recipeDisplays)) { + RuntimeProbe.require(++settledWorldTicks < 400, "Native client recipe displays arrived"); + return; + } + RecipePreviewModels.check(minecraft, recipeDisplays); + checkBiomeTints(minecraft); + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("TERRAIN_SMOOTHER_CLIENT_WORLD_PASS"); + minecraft.stop(); integrated = false; } return; } - if (minecraft.currentScreen == null) { + if (minecraft.gui.screen() == null || minecraft.gui.overlay() != null) { return; } - ran = true; - try { - MineralogyProbe.run(); - FineGrassModelProbe.run(minecraft); - ConfigScreenProbe.run(minecraft); - checkModel(minecraft, RuntimeProbe.probeOre.getDefaultState()); + if (!resourcesChecked) { + MineralogyProbe.run(); + FineGrassModelProbe.run(minecraft); + checkModel(minecraft, RuntimeProbe.probeOre.defaultBlockState()); - for (net.minecraft.block.state.IBlockState state : zone.moddev.mc.skysgrassslabs.init.ModBlocks.GRASS_SLAB - .getBlockState().getValidStates()) { - checkModel(minecraft, state); - } - for (zone.moddev.mc.skysbuildingpieces.content.PieceBlock block : zone.moddev.mc.skysbuildingpieces.content.Pieces.BLOCKS - .values()) { - for (net.minecraft.block.state.IBlockState state : block.getBlockState().getValidStates()) { - if (state.getValue(zone.moddev.mc.skysbuildingpieces.content.PieceBlock.META) - / block.palette.shape.states >= block.palette.materials.size()) { - continue; + for (net.minecraft.world.level.block.Block block : new net.minecraft.world.level.block.Block[] { + zone.moddev.mc.skysterrainsmoother.content.SandContent.slab, + zone.moddev.mc.skysterrainsmoother.content.SandContent.redSlab }) { + for (net.minecraft.world.level.block.state.BlockState state : block.getStateDefinition().getPossibleStates()) { + checkModel(minecraft, state); } - + } + for (net.minecraft.world.level.block.state.BlockState state : zone.moddev.mc.skysgrassslabs.init.ModBlocks.GRASS_SLAB.get() + .getStateDefinition().getPossibleStates()) { checkModel(minecraft, state); } + for (zone.moddev.mc.skysbuildingpieces.content.PieceBlock block : zone.moddev.mc.skysbuildingpieces.content.Pieces.BLOCKS + .values()) { + for (net.minecraft.world.level.block.state.BlockState state : block.getStateDefinition().getPossibleStates()) { + checkModel(minecraft, state); + } + } + resourcesChecked = true; + ConfigScreenProbe.begin(minecraft); + return; } - try (Writer writer = new FileWriter(new File(minecraft.gameDir, "terrain-smoother-client-pass.txt"))) { + if (!ConfigScreenProbe.tick(minecraft)) return; + ran = true; + try (Writer writer = new FileWriter(new File(minecraft.gameDirectory, "terrain-smoother-client-pass.txt"))) { writer.write("TERRAIN_SMOOTHER_CLIENT_PASS\n"); } - System.out.println("TERRAIN_SMOOTHER_CLIENT_PASS"); + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("TERRAIN_SMOOTHER_CLIENT_PASS"); } catch (IOException failure) { throw new IllegalStateException(failure); } if (System.getProperty("skysterrainsmoother.integrationPhase", "").startsWith("client-")) { integrated = true; - minecraft.launchIntegratedServer("terrain-smoother-world", "Terrain Smoother Test", - new net.minecraft.world.WorldSettings(4815162342L, net.minecraft.world.GameType.CREATIVE, true, - false, net.minecraft.world.WorldType.DEFAULT)); + if (new File(minecraft.gameDirectory, "saves/terrain-smoother-world/level.dat").isFile()) { + minecraft.createWorldOpenFlows().openWorld("terrain-smoother-world", + () -> minecraft.gui.setScreen(new net.minecraft.client.gui.screens.TitleScreen())); + } else { + net.minecraft.world.level.LevelSettings settings = new net.minecraft.world.level.LevelSettings( + "Terrain Smoother Test", net.minecraft.world.level.GameType.CREATIVE, + net.minecraft.world.level.LevelSettings.DifficultySettings.DEFAULT, true, + net.minecraft.world.level.WorldDataConfiguration.DEFAULT); + minecraft.createWorldOpenFlows().createFreshLevel("terrain-smoother-world", settings, + new net.minecraft.world.level.levelgen.WorldOptions(4815162342L, false, false), + net.minecraft.world.level.levelgen.presets.WorldPresets::createNormalWorldDimensions, minecraft.gui.screen()); + } + } else { + minecraft.stop(); + } + } + + private void confirmDisposableWorld(Minecraft client) { + if (!new File(client.gameDirectory, "terrain-smoother-client-pass.txt").isFile() + || !System.getProperty("skysterrainsmoother.integrationPhase", "").startsWith("client-")) { + return; + } + net.minecraft.client.gui.screens.Screen screen = client.gui.screen(); + if (screen == null || !(screen.getTitle().getContents() + instanceof net.minecraft.network.chat.contents.TranslatableContents title) + || !title.getKey().equals("selectWorld.backupQuestion.experimental")) { + return; + } + net.minecraft.network.chat.Component proceed; + if (screen instanceof net.minecraft.client.gui.screens.BackupConfirmScreen) { + proceed = net.minecraft.network.chat.Component.translatable("selectWorld.backupJoinSkipButton"); + } else if (screen instanceof net.minecraft.client.gui.screens.ConfirmScreen) { + proceed = net.minecraft.network.chat.CommonComponents.GUI_PROCEED; } else { - minecraft.shutdown(); + return; } + for (net.minecraft.client.gui.components.events.GuiEventListener child : screen.children()) { + if (child instanceof net.minecraft.client.gui.components.Button) { + net.minecraft.client.gui.components.Button button = + (net.minecraft.client.gui.components.Button) child; + if (button.getMessage().equals(proceed)) { + // Only the marked probe's disposable world reaches this confirmation. + // The prompt stays visible while the asynchronous load starts. + confirmedWorld = true; + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("TERRAIN_NATIVE_EXPERIMENTAL_WORLD_CONFIRMATION"); + button.onPress(new net.minecraft.client.input.KeyEvent(org.lwjgl.sdl.SDLKeycode.SDLK_RETURN, 0, 0)); + return; + } + } + } + throw new AssertionError("The native experimental-world confirmation has no proceed action"); } - private static void checkModel(Minecraft minecraft, net.minecraft.block.state.IBlockState state) { - net.minecraft.client.renderer.block.model.IBakedModel model = minecraft.getBlockRendererDispatcher() - .getModelForState(state); + private static void checkBiomeTints(Minecraft client) { + net.minecraft.core.BlockPos sample = client.player.blockPosition(); + int expected = tint(client, net.minecraft.world.level.block.Blocks.GRASS_BLOCK.defaultBlockState(), sample); + int count = 0; + for (zone.moddev.mc.skysbuildingpieces.content.PieceBlock block : + zone.moddev.mc.skysbuildingpieces.content.Pieces.BLOCKS.values()) { + if (block.palette.group.equals("grass")) { + RuntimeProbe.require(tint(client, block.defaultBlockState(), sample) + == expected, "Fine grass uses the actual client biome tint"); + count++; + } + } + RuntimeProbe.require(count > 0, "Grass pieces reached the colour checks"); + RuntimeProbe.require(tint(client, zone.moddev.mc.skysgrassslabs.init.ModBlocks.GRASS_SLAB.get().defaultBlockState(), sample) + == expected, "Grass slabs retain the actual client biome tint"); + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("TERRAIN_SMOOTHER_CLIENT_TINT_PASS palettes=" + count); + } + + private static int tint(Minecraft client, net.minecraft.world.level.block.state.BlockState state, + net.minecraft.core.BlockPos position) { + var source = client.getBlockColors().getTintSource(state, 0); + RuntimeProbe.require(source != null, "Missing block tint: " + state); + return source.colorInWorld(state, client.level, position); + } - RuntimeProbe.require(model != minecraft.getBlockRendererDispatcher().getBlockModelShapes().getModelManager() - .getMissingModel(), "missing model " + state); + private static void checkModel(Minecraft minecraft, net.minecraft.world.level.block.state.BlockState state) { + var models = minecraft.getModelManager().getBlockStateModelSet(); + var model = models.get(state); + RuntimeProbe.require(model != models.missingModel(), "missing model " + state); + var parts = new java.util.ArrayList(); + model.collectParts(net.minecraft.util.RandomSource.create(0), parts); - for (net.minecraft.util.EnumFacing face : new net.minecraft.util.EnumFacing[] { null, - net.minecraft.util.EnumFacing.DOWN, net.minecraft.util.EnumFacing.UP, - net.minecraft.util.EnumFacing.NORTH, net.minecraft.util.EnumFacing.SOUTH, - net.minecraft.util.EnumFacing.WEST, net.minecraft.util.EnumFacing.EAST }) { - for (net.minecraft.client.renderer.block.model.BakedQuad quad : model.getQuads(state, face, 0)) { - RuntimeProbe.require(!quad.getSprite().getIconName().contains("missingno"), "missing texture " + state); + for (net.minecraft.core.Direction face : new net.minecraft.core.Direction[] { null, + net.minecraft.core.Direction.DOWN, net.minecraft.core.Direction.UP, + net.minecraft.core.Direction.NORTH, net.minecraft.core.Direction.SOUTH, + net.minecraft.core.Direction.WEST, net.minecraft.core.Direction.EAST }) { + for (net.minecraft.client.resources.model.geometry.BakedQuad quad : parts.stream() + .flatMap(part -> part.getQuads(face).stream()).toList()) { + RuntimeProbe.require(!quad.materialInfo().sprite().contents().name().toString().contains("missingno"), "missing texture " + state); } } } diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ConfigScreenProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ConfigScreenProbe.java index 0cdc543..4644486 100644 --- a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ConfigScreenProbe.java +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ConfigScreenProbe.java @@ -1,91 +1,176 @@ package zone.moddev.mc.skysterrainsmoother.probe; +import java.util.ArrayList; +import java.util.List; import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiScreen; -import net.minecraft.client.resources.I18n; -import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.common.config.Configuration; -import net.minecraftforge.fml.client.FMLClientHandler; -import net.minecraftforge.fml.client.IModGuiFactory; -import net.minecraftforge.fml.client.config.GuiConfig; -import net.minecraftforge.fml.client.event.ConfigChangedEvent; -import net.minecraftforge.fml.common.Loader; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.resources.language.I18n; import zone.moddev.mc.skysgrassslabs.config.SkysGrassSlabsConfig; -import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; import zone.moddev.mc.skysterrainsmoother.config.TerrainSmootherConfig; -/** Opens the actual registered screens inside the disposable client, without taking desktop control. */ +/** Opens, renders and saves the native settings screens, preserving the running session. */ final class ConfigScreenProbe { - static void run(Minecraft minecraft) { - ModMenuProbe.run(minecraft); - for (String modId : new String[] { "skysgrassslabs", "skysterrainsmoother" }) { - IModGuiFactory factory = FMLClientHandler.instance() - .getGuiFactoryFor(Loader.instance().getIndexedModList().get(modId)); - RuntimeProbe.require(factory != null, "Mods list has a config factory for " + modId); - RuntimeProbe.require(factory.hasConfigGui(), "Config button is enabled for " + modId); - GuiScreen screen = factory.createConfigGui(minecraft.currentScreen); - net.minecraft.client.gui.ScaledResolution resolution = new net.minecraft.client.gui.ScaledResolution(minecraft); - screen.setWorldAndResolution(minecraft, resolution.getScaledWidth(), resolution.getScaledHeight()); - GuiConfig configScreen = (GuiConfig) screen; - int expectedEntries = modId.equals("skysgrassslabs") ? 2 : 3; - RuntimeProbe.require(configScreen.configElements.size() == expectedEntries - && configScreen.entryList.listEntries.size() == expectedEntries - && configScreen.allRequireMcRestart, "all settings have widgets and require restart"); - for (net.minecraftforge.fml.client.config.IConfigElement element : configScreen.configElements) { - RuntimeProbe.require(!I18n.format(element.getLanguageKey()).equals(element.getLanguageKey()), - "setting has a readable translated label: " + element.getName()); - } - minecraft.getFramebuffer().bindFramebuffer(true); - minecraft.entityRenderer.setupOverlayRendering(); - screen.drawScreen(0, 0, 0); - net.minecraft.util.ScreenShotHelper.saveScreenshot(minecraft.gameDir, modId + "-config.png", - minecraft.displayWidth, minecraft.displayHeight, minecraft.getFramebuffer()); - RuntimeProbe.require(new java.io.File(minecraft.gameDir, "screenshots/" + modId + "-config.png").isFile(), - "config screen screenshot saved"); - screen.onGuiClosed(); - } - - Configuration grass = (Configuration) grassConfiguration("configuration"); - Configuration terrain = TerrainSmootherConfig.configuration(); - boolean activeGrass = SkysGrassSlabsConfig.generateGrassSlabs(); - int activeLevel = SkysTerrainSmoother.level; + private static Screen parent; + private static Screen screen; + private static int modIndex; + private static int choiceIndex; + private static int stage; + private static int settleTicks; + private static int tooltips; + private static long deadline; + private static boolean activeGrass; + private static boolean activeTerrain; + private static int activeLevel; + private static List original; + private static String[] keys; - grass.get("worldgen", "generateGrassSlabs", true).set(!activeGrass); - int pendingLevel = activeLevel == 3 ? 1 : 3; - terrain.getCategory("worldgen").get("smoothingLevel").set(pendingLevel); - MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("unrelated", null, false, true)); - RuntimeProbe.require(read(grass).get("worldgen", "generateGrassSlabs", true).getBoolean() == activeGrass - && read(terrain).get("worldgen", "smoothingLevel", 1).getInt() == activeLevel, - "other mods cannot save these settings"); - MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("skysgrassslabs", null, false, true)); - MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("skysterrainsmoother", null, false, true)); - // Forge's save() does not clear every dirty flag. Read the files back instead. - RuntimeProbe.require(read(grass).get("worldgen", "generateGrassSlabs", true).getBoolean() != activeGrass - && read(terrain).get("worldgen", "smoothingLevel", 1).getInt() == pendingLevel, - "actual GUI event handlers saved both files"); - RuntimeProbe.require(SkysGrassSlabsConfig.generateGrassSlabs() == activeGrass - && SkysTerrainSmoother.level == activeLevel, "pending GUI choices leave active settings unchanged"); + static void begin(Minecraft minecraft) { + parent = minecraft.gui.screen(); + activeGrass = SkysGrassSlabsConfig.generateGrassSlabs(); + activeTerrain = TerrainSmootherConfig.enabled(); + activeLevel = TerrainSmootherConfig.level(); + modIndex = 0; + tooltips = 0; + stage = 0; + } - grass.get("worldgen", "generateGrassSlabs", true).set(activeGrass); - terrain.getCategory("worldgen").get("smoothingLevel").set(activeLevel); - grassConfiguration("saveChanges"); - TerrainSmootherConfig.saveChanges(); - System.out.println("SKY_CONFIG_SCREENS_PASS factories=2 widgets=5 restart_safe=true"); + static boolean tick(Minecraft minecraft) { + if (modIndex == 2) { + minecraft.gui.setScreen(parent); + RuntimeProbe.require(tooltips == 6, "All six settings were exercised"); + zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother.logger.info("SKY_CONFIG_SCREENS_PASS factories=2 widgets=6 restart_safe=true"); + return true; + } + String modId = modIndex == 0 ? "skysgrassslabs" : "skysterrainsmoother"; + if (stage == 0) { + screen = ModMenuProbe.open(minecraft, modId, parent); + keys = modIndex == 0 ? new String[] { "generateGrassSlabs", "forceReplaceBuildingBricksSlabs", + "forceReplaceGrassSlabsModContent" } + : new String[] { "enabled", "smoothingLevel", "enableMineralogy" }; + RuntimeProbe.require(buttons(screen).size() == keys.length + 1, "Each setting has its own control"); + original = new ArrayList<>(); + for (int index = 0; index < keys.length; index++) { + original.add(buttons(screen).get(index).getMessage().getString()); + } + choiceIndex = 0; + stage = 1; + } else if (stage == 1) { + Button button = buttons(screen).get(choiceIndex); + String key = "config." + modId + "." + keys[choiceIndex]; + RuntimeProbe.require(!I18n.get(key).equals(key) && !I18n.get(key + ".tooltip").equals(key + ".tooltip"), + "Readable label and tooltip for " + key); + var holder = (net.minecraft.client.gui.components.WidgetTooltipHolder) ModMenuProbe.field( + net.minecraft.client.gui.components.AbstractWidget.class, button, "tooltip"); + var tooltip = holder.get(); + RuntimeProbe.require(tooltip != null, "The button exposes its tooltip: " + key); + var message = (net.minecraft.network.chat.Component) ModMenuProbe.field( + net.minecraft.client.gui.components.Tooltip.class, tooltip, "message"); + RuntimeProbe.require(message.getString().equals(I18n.get(key + ".tooltip")), + "The button uses the correct translated tooltip: " + key); + button.onPress(new net.minecraft.client.input.KeyEvent(org.lwjgl.sdl.SDLKeycode.SDLK_RETURN, 0, 0)); + RuntimeProbe.require(!button.getMessage().getString().equals(original.get(choiceIndex)), "The pending choice changes"); + tooltips++; + if (++choiceIndex == keys.length) { + buttons(screen).get(keys.length).onPress(new net.minecraft.client.input.KeyEvent(org.lwjgl.sdl.SDLKeycode.SDLK_RETURN, 0, 0)); + deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10); + settleTicks = 0; + stage = 2; + } + } else if (stage == 2) { + // Native config saves and file watches are asynchronous. Reopening on + // a later frame also follows the same path a player uses. + RuntimeProbe.require(System.nanoTime() < deadline, "Saved config choices became readable for " + modId); + Screen pending = newConfigScreen(modId); + minecraft.gui.setScreen(pending); + boolean changed = true; + for (int index = 0; index < keys.length; index++) { + changed &= !buttons(pending).get(index).getMessage().getString().equals(original.get(index)); + } + if (!changed || !configuredValuesMatchDisk(modId)) { + settleTicks = 0; + return false; + } + if (++settleTicks < 20) return false; + screen = ModMenuProbe.open(minecraft, modId, parent); + for (int index = 0; index < keys.length; index++) { + Button button = buttons(screen).get(index); + RuntimeProbe.require(!button.getMessage().getString().equals(original.get(index)), "Saved choices are reloaded: " + keys[index]); + for (int attempt = 0; attempt < 3 && !button.getMessage().getString().equals(original.get(index)); attempt++) { + button.onPress(new net.minecraft.client.input.KeyEvent(org.lwjgl.sdl.SDLKeycode.SDLK_RETURN, 0, 0)); + } + RuntimeProbe.require(button.getMessage().getString().equals(original.get(index)), "Restore the disposable configuration"); + } + buttons(screen).get(keys.length).onPress(new net.minecraft.client.input.KeyEvent(org.lwjgl.sdl.SDLKeycode.SDLK_RETURN, 0, 0)); + settleTicks = 0; + stage = 3; + } else { + RuntimeProbe.require(System.nanoTime() < deadline, "Original config choices restored for " + modId); + Screen restored = newConfigScreen(modId); + minecraft.gui.setScreen(restored); + for (int index = 0; index < keys.length; index++) { + if (!buttons(restored).get(index).getMessage().getString().equals(original.get(index))) { + settleTicks = 0; + return false; + } + } + if (!configuredValuesMatchDisk(modId)) { + settleTicks = 0; + return false; + } + if (++settleTicks < 20) return false; + RuntimeProbe.require(SkysGrassSlabsConfig.generateGrassSlabs() == activeGrass + && TerrainSmootherConfig.enabled() == activeTerrain && TerrainSmootherConfig.level() == activeLevel, + "Saving choices leaves this session unchanged"); + modIndex++; + stage = 0; + } + return false; } - private static Configuration read(Configuration source) { - Configuration written = new Configuration(source.getConfigFile()); - written.load(); - return written; + private static Screen newConfigScreen(String modId) { + return modId.equals("skysgrassslabs") + ? new zone.moddev.mc.skysgrassslabs.client.GrassSlabsConfigScreen(parent) + : new zone.moddev.mc.skysterrainsmoother.client.TerrainSmootherConfigScreen(parent); } - private static Object grassConfiguration(String method) { + private static boolean configuredValuesMatchDisk(String modId) { + String[] values = modId.equals("skysgrassslabs") + ? new String[] { + Boolean.toString(SkysGrassSlabsConfig.configuredGrassGeneration()), + Boolean.toString(SkysGrassSlabsConfig.configuredLegacyReplacement()), + Boolean.toString(SkysGrassSlabsConfig.configuredGrassSlabsReplacement()) + } + : new String[] { + Boolean.toString(TerrainSmootherConfig.configuredEnabled()), + Integer.toString(TerrainSmootherConfig.configuredLevel()), + Boolean.toString(TerrainSmootherConfig.configuredMineralogy()) + }; try { - // This is a build-only check, not an addition to the Grass Slabs extension API. - return SkysGrassSlabsConfig.class.getMethod(method).invoke(null); - } catch (ReflectiveOperationException failure) { - throw new IllegalStateException("Use the matching Grass Slabs config-screen candidate", failure); + String contents = java.nio.file.Files.readString(java.nio.file.Paths.get( + "config", modId + "-common.toml"), java.nio.charset.StandardCharsets.UTF_8); + for (int index = 0; index < keys.length; index++) { + java.util.regex.Matcher value = java.util.regex.Pattern.compile( + "(?m)^\\h*" + java.util.regex.Pattern.quote(keys[index]) + + "\\h*=\\h*(true|false|[1-3])\\h*$").matcher(contents); + if (!value.find() || !value.group(1).equals(values[index]) || value.find()) { + return false; + } + } + return true; + } catch (java.io.IOException failure) { + throw new IllegalStateException("Cannot read the saved test configuration for " + modId, failure); + } + } + + private static List