diff --git a/.github/test-base-metals-pin.sh b/.github/test-base-metals-pin.sh index 8d5da556..240854aa 100644 --- a/.github/test-base-metals-pin.sh +++ b/.github/test-base-metals-pin.sh @@ -24,7 +24,7 @@ chmod +x "$scratch/bin/gh" export PATH="$scratch/bin:$PATH" commit=1111111111111111111111111111111111111111 cd "$scratch/project" -for minecraft in 1.20.6 1.21.1 1.21.11; do +for minecraft in 1.20.6 1.21.1 1.21.11 26.1.2; do printf 'minecraft_version=%s\nbasemetals_source_commit=%s\n' "$minecraft" "$commit" > gradle.properties export EXPECTED_COMPARE="repos/MinecraftModDevelopmentMods/BaseMetals/compare/$commit...master-$minecraft" export GITHUB_OUTPUT="$scratch/output" @@ -44,9 +44,11 @@ for minecraft in 1.20.6 1.21.1 1.21.11; do test ! -s "$GITHUB_OUTPUT" done done -printf 'minecraft_version=invalid\nbasemetals_source_commit=%s\n' "$commit" > gradle.properties -if bash "$verify" > "$scratch/invalid.log" 2>&1; then - echo 'Incorrectly accepted an invalid target' >&2 - exit 1 -fi +for minecraft in invalid 26.1 26.1.2/evil master-26.1.2 0.1.2; do + printf 'minecraft_version=%s\nbasemetals_source_commit=%s\n' "$minecraft" "$commit" > gradle.properties + if bash "$verify" > "$scratch/invalid.log" 2>&1; then + echo "Incorrectly accepted an invalid target: $minecraft" >&2 + exit 1 + fi +done echo 'PASS: target-derived Base Metals ancestry guards and rejection controls' diff --git a/.github/verify-base-metals-pin.sh b/.github/verify-base-metals-pin.sh index e526e396..f8bc2bb2 100644 --- a/.github/verify-base-metals-pin.sh +++ b/.github/verify-base-metals-pin.sh @@ -8,7 +8,7 @@ if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then fi minecraft="$(sed -n 's/^minecraft_version=//p' gradle.properties)" -if [[ ! "$minecraft" =~ ^1\.[0-9]+\.[0-9]+$ ]]; then +if [[ ! "$minecraft" =~ ^(1|[2-9][0-9])\.[0-9]+\.[0-9]+$ ]]; then echo "::error::Expected a complete Minecraft target version in gradle.properties" exit 1 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a97454a8..8f6a6a1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,16 @@ -name: basePower 1.21.11 CI +name: basePower 26.1.2 CI on: push: - branches: [master-1.21.11, 'feature/**'] + branches: [master-26.1.2, 'feature/**'] pull_request: - branches: [master-1.21.11] + branches: [master-26.1.2] permissions: contents: read concurrency: - group: basepower-1.21.11-${{ github.workflow }}-${{ github.ref }} + group: basepower-26.1.2-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Test target-derived dependency guards run: bash .github/test-base-metals-pin.sh - - name: Require the Base Metals pin on upstream master-1.21.11 + - name: Require the Base Metals pin on upstream master-26.1.2 id: base-metals-pin env: GH_TOKEN: ${{ github.token }} @@ -34,34 +34,24 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/OreSpawn - ref: ecad75b24309a344f712d898d717c6a8823fc9f2 + ref: 101fd12b598934c353e417c57edc5c2bbefd5f53 path: .ci-dependencies/OreSpawn - name: Check out pinned Base Metals uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/BaseMetals - ref: 616d527e50e4bf0137eeb9c44fa586e34a0452a0 + ref: 331fa46914f854db71a8c33500cc559a8878b7d3 path: .ci-dependencies/BaseMetals - - name: Install pinned Java 25 Mavenizer runtime + - name: Install Java 25 for Gradle, tools and Minecraft uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: '25.0.3+9.0.LTS' - - name: Install Java 8 launcher helper - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '8.0.502+7' - - name: Install Java 21 for Gradle and production - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '21.0.7+6.0.LTS' - name: Use pinned build toolchains # These are Gradle project properties, not JVM system properties. shell: bash run: >- - echo "MMD_GRADLE_TOOLCHAINS=-Porg.gradle.java.installations.paths=$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64 + echo "MMD_GRADLE_TOOLCHAINS=-Porg.gradle.java.installations.paths=$JAVA_HOME -Porg.gradle.java.installations.auto-detect=false -Porg.gradle.java.installations.auto-download=false" >> "$GITHUB_ENV" - name: Set up Gradle @@ -78,8 +68,8 @@ jobs: (cd "$basemetals" && bash gradle/stage-orespawn-release.sh "$mirror") (cd "$orespawn" && bash gradlew $MMD_GRADLE_TOOLCHAINS jar --no-daemon --stacktrace) (cd "$basemetals" && bash gradlew $MMD_GRADLE_TOOLCHAINS jar -PorespawnVerificationRepository="$mirror" --no-daemon --stacktrace) - osjar="$orespawn/build/libs/OreSpawn-4.1.0.121111.jar" - bmjar="$basemetals/build/libs/BaseMetals-3.1.2.121111.jar" + osjar="$orespawn/build/libs/OreSpawn-4.1.1.2601021.jar" + bmjar="$basemetals/build/libs/BaseMetals-3.1.2.2601021.jar" test -f "$osjar" && test -f "$bmjar" echo "orespawn=$osjar" >> "$GITHUB_OUTPUT" echo "basemetals=$bmjar" >> "$GITHUB_OUTPUT" @@ -90,12 +80,12 @@ jobs: writeReleaseChecksums verifyEclipseProductionClasspath -PbaseMetalsDeobfJar=${{ steps.dependencies.outputs.basemetals }} -PoreSpawnDeobfJar=${{ steps.dependencies.outputs.orespawn }} - -Pforge61MavenizerJar=${{ steps.dependencies.outputs.mavenizer }} + -Pforge64MavenizerJar=${{ steps.dependencies.outputs.mavenizer }} --no-daemon --stacktrace - name: Upload audited release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: basePower-1.21.11-${{ github.sha }} + name: basePower-26.1.2-${{ github.sha }} if-no-files-found: error retention-days: 30 path: | @@ -106,7 +96,7 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: basePower-1.21.11-diagnostics-${{ github.sha }} + name: basePower-26.1.2-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 3d24428e..218a77b0 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [master-1.21.11, 'feature/**'] + branches: [master-26.1.2, 'feature/**'] pull_request: - branches: [master-1.21.11] + branches: [master-26.1.2] schedule: - cron: '47 7 * * 4' @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Test target-derived dependency guards run: bash .github/test-base-metals-pin.sh - - name: Require the Base Metals pin on upstream master-1.21.11 + - name: Require the Base Metals pin on upstream master-26.1.2 id: base-metals-pin env: GH_TOKEN: ${{ github.token }} @@ -34,34 +34,24 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/OreSpawn - ref: ecad75b24309a344f712d898d717c6a8823fc9f2 + ref: 101fd12b598934c353e417c57edc5c2bbefd5f53 path: .ci-dependencies/OreSpawn - name: Check out pinned Base Metals uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/BaseMetals - ref: 616d527e50e4bf0137eeb9c44fa586e34a0452a0 + ref: 331fa46914f854db71a8c33500cc559a8878b7d3 path: .ci-dependencies/BaseMetals - - name: Install pinned Java 25 Mavenizer runtime + - name: Install Java 25 for Gradle, tools and Minecraft uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: '25.0.3+9.0.LTS' - - name: Install Java 8 launcher helper - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '8.0.502+7' - - name: Install Java 21 for Gradle and production - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '21.0.7+6.0.LTS' - name: Use pinned build toolchains # These are Gradle project properties, not JVM system properties. shell: bash run: >- - echo "MMD_GRADLE_TOOLCHAINS=-Porg.gradle.java.installations.paths=$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64 + echo "MMD_GRADLE_TOOLCHAINS=-Porg.gradle.java.installations.paths=$JAVA_HOME -Porg.gradle.java.installations.auto-detect=false -Porg.gradle.java.installations.auto-download=false" >> "$GITHUB_ENV" - name: Set up Gradle @@ -79,8 +69,8 @@ jobs: chmod +x "$orespawn/gradlew" "$basemetals/gradlew" (cd "$orespawn" && ./gradlew $MMD_GRADLE_TOOLCHAINS jar --no-daemon --stacktrace) (cd "$basemetals" && ./gradlew $MMD_GRADLE_TOOLCHAINS jar -PorespawnVerificationRepository="$mirror" --no-daemon --stacktrace) - echo "orespawn=$orespawn/build/libs/OreSpawn-4.1.0.121111.jar" >> "$GITHUB_OUTPUT" - echo "basemetals=$basemetals/build/libs/BaseMetals-3.1.2.121111.jar" >> "$GITHUB_OUTPUT" + echo "orespawn=$orespawn/build/libs/OreSpawn-4.1.1.2601021.jar" >> "$GITHUB_OUTPUT" + echo "basemetals=$basemetals/build/libs/BaseMetals-3.1.2.2601021.jar" >> "$GITHUB_OUTPUT" echo "mavenizer=$mavenizer" >> "$GITHUB_OUTPUT" - name: Initialize CodeQL uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 @@ -93,6 +83,6 @@ jobs: ./gradlew $MMD_GRADLE_TOOLCHAINS clean classes verifyReleaseDependencies --rerun-tasks --no-build-cache --no-daemon --stacktrace \ -PbaseMetalsDeobfJar=${{ steps.dependencies.outputs.basemetals }} \ -PoreSpawnDeobfJar=${{ steps.dependencies.outputs.orespawn }} \ - -Pforge61MavenizerJar=${{ steps.dependencies.outputs.mavenizer }} + -Pforge64MavenizerJar=${{ steps.dependencies.outputs.mavenizer }} - name: Analyze uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 diff --git a/.github/workflows/validate-gradle-build.yml b/.github/workflows/validate-gradle-build.yml index b37dfc94..531f53fe 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.21.11 + - master-26.1.2 - 'feature/**' pull_request: branches: - - master-1.21.11 + - master-26.1.2 permissions: contents: read diff --git a/CHANGELOG.txt b/CHANGELOG.txt index b163f18f..300f634a 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,19 @@ +basePower 2.4.16.2601021 (Minecraft 26.1.2) + +- Require OreSpawn 4.1.1 so old generation rules keep their saved settings without enabling duplicate modern ore rules. +- Bring basePower to Forge 64 and Java 25 without renaming machines, items or saved data. +- Use the new screen extraction API and native sprite clipping for fluid gauges. +- Keep oil models tied to the current animated atlas, including after resource reloads. +- Register only our own block textures in the atlas, so another mod's small entity textures cannot break client startup. +- Use Minecraft's native container lock check and keep conveyors out of either locked half of a double chest. +- Keep the returned container when the fuel helper consumes the last item in a stack. +- Keep the saved enchantments on old dropped items and chest-minecart contents when Base Metals upgrades the world first. +- Update old OreSpawn output block names without replacing saved generation settings. Keep backups and reports, and stop for review rather than guess when saved rules conflict. +- Finish imported generation settings before opening the first world, including the old vanilla quartz name. +- Move the existing farmer and toolsmith trades to Minecraft's native trade data and tags, keeping their prices and quantities. +- Prepare selected, locked worlds before Minecraft relocates old world and player files. Keep world previews and fallback files read-only. +- Keep the shared Base Metals migration hook and add new test evidence for this target. Machine, upgrade, restart, graphical and two-world endurance checks pass, and manual testing is accepted. + basePower 2.4.15.121111 (Minecraft 1.21.11) - Bring basePower to Forge 61 and Java 21, keeping the existing machine and world identities. diff --git a/README.md b/README.md index 6dcd0d22..8b74b4ec 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,35 @@ # basePower -**Minecraft 1.21.11 (Forge), version 2.4.15.121111.** - -This release has passed automated qualification and manual testing. It is -available on [CurseForge](https://www.curseforge.com/minecraft/mc-mods/basepower/files/9566998), -[MMD Maven](https://maven.moddev.zone/releases/zone/moddev/mc/basepower/basePower/2.4.15.121111/) -and [GitHub](https://github.com/MinecraftModDevelopmentMods/basePower/releases/tag/2.4.15.121111). -The [1.21.11 port record](docs/1.21.11/PORT-STATUS.md) records the checks and -remaining coverage limits; earlier versions do not qualify this release. - -The current Base Metals-installed test set has passed its automated machine, -upgrade, graphical and two-world 30-minute endurance checks. Manual testing has -also been accepted. See the [qualification record](docs/1.21.11/QUALIFICATION.md) -for the exact jars, results and remaining human checks. -The [release-pin recheck](docs/1.21.11/QUALIFICATION.md#release-pin-recheck) -also covers the final expansion manifests, a fresh old-world upgrade and -another two-world endurance run with exact saved-state replay. - -For the combined Eclipse test, use `runClientAllAdvantage.launch` in this -project. It loads the matching baseSteam and baseElectric candidates. The -[port record](docs/1.21.11/PORT-STATUS.md) separates the automated checks from -the remaining manual gameplay and visual checks. +**Minecraft 26.1.2 (Forge), version 2.4.16.2601021.** + +This branch has passed its +[26.1.2 automated qualification](docs/26.1.2/QUALIFICATION.md) and manual +playtesting. Publication is being prepared separately. The +[port record](docs/26.1.2/PORT-STATUS.md) keeps the earlier findings separate. +The [1.21.11 qualification](docs/1.21.11/QUALIFICATION.md) remains historical +evidence, not proof of this port. + +Use the generated `runClientAllAdvantage.launch` in Eclipse to load the +matching baseSteam and baseElectric candidates with basePower. basePower continues DrCyano's Power Advantage. It provides shared power, fluids and item transport for baseSteam and baseElectric. -## Playing On 1.21.11 +## Testing On 26.1.2 -- Install [MMD OreSpawn 4.1.0.121111](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn/files/9268464) on both client and server. -- Install [Base Metals 3.1.2.121111](https://www.curseforge.com/minecraft/mc-mods/base-metals/files/9566738) - on both client and server. Older Base Metals builds do not have the shared - upgrade hook required by this release. +- Use MMD OreSpawn `4.1.1.2601021` on both client and server. This includes + the fix that keeps saved ore settings when providers rename their rules. +- This candidate requires the local Base Metals `3.1.2.2601021` migration-hook + build on both client and server. It has not been published; the public + `3.1.1` jar is not a substitute for the upgrade tests. - baseSteam and baseElectric are separate mods. Use only their matching - 1.21.11 builds. Additional Loot Tables is not needed for the built-in loot. + 26.1.2 builds. Additional Loot Tables is not needed for the built-in loot. **Back up the entire world before upgrading. Test only disposable copies.** The port keeps existing registry names and machine data. The upgrade checks -use disposable copies of original Advantage and released base* saves, including -connected machines, and check them again after a full restart. Check that the -other mods in your world support 1.21.11 before you upgrade. +used disposable copies of original Advantage and released base* saves, +including connected machines, and check them after a full restart. Check that +the other mods in your world support 26.1.2 before attempting an upgrade. The accepted [1.14 results](docs/1.14/BROADER-QUALIFICATION.md) are historical evidence, not qualification of this port or a whole modpack. @@ -46,22 +37,21 @@ Recipe-book discovery now follows relevant ingredients. See the [all-Advantage m The [manufacturing progression audit](docs/1.13/MANUFACTURING-PROGRESSION.md) traces survival parts from raw resources, including configured material aliases, processing, loot-only parts and automated restart checks. -## Building Minecraft 1.21.11 +## Building Minecraft 26.1.2 -The build uses Forge 61.1.0, official `1.21.11` mappings, -MCP `20251223.124241`, ForgeGradle 7.0.34 and Gradle 9.6.1. -Use Java 21 for Gradle, compilation and Minecraft. Mavenizer also -needs Java 25; the development launcher helper uses Java 8. Pass the installed homes using -`-Porg.gradle.java.installations.paths=,,` and disable +The build uses Forge 64.1.5, official `26.1.2` names, +MCP `20260409.101008`, ForgeGradle 7.0.34 and Gradle 9.6.1. +Use Java `25.0.3+9` for Gradle, compilation, Mavenizer and Minecraft. Pass its home using +`-Porg.gradle.java.installations.paths=` and disable automatic discovery and downloads for a reproducibility check. -Use the pinned Base Metals `3.1.2.121111` and OreSpawn `4.1.0.121111` jars. +Use the pinned Base Metals `3.1.2.2601021` and OreSpawn `4.1.1.2601021` jars. The source pins and checksums are in `gradle.properties`. -Forge 61 also needs the Mavenizer compatibility jar in the pinned OreSpawn +Forge 64 also needs the Mavenizer compatibility jar in the pinned OreSpawn checkout's `ci-fixtures/tools/` directory. This is the same checksum-verified source preparation tool used by OreSpawn; its manifest makes no source changes -for Forge 61.1.0. It uses Java 25 and is not part of the mod or game runtime. -Set `-Pforge61MavenizerJar=` when that checkout is in another location. +for this target. It is not part of the mod or game runtime. +Set `-Pforge64MavenizerJar=` when that checkout is in another location. Use the pinned Base Metals 3.1.2 build for both new worlds and upgrades. It lets both mods preserve old items and machines before Minecraft converts the chunk. An older Base Metals jar is not a substitute for this upgrade path. @@ -75,8 +65,8 @@ gradlew.bat clean check build javadoc genEclipseRuns eclipse isolateEclipseProdu The default dependency jars are: ```text -D:\BaseMetals\.verification-basemetals12111\Advantage shared hook\build\libs\BaseMetals-3.1.2.121111.jar -D:\MinecraftMineralogy\OS12111\OreSpawn\build\libs\OreSpawn-4.1.0.121111.jar +D:\BaseMetals\BaseMetals 2612\BaseMetals\build\libs\BaseMetals-3.1.2.2601021.jar +D:\MinecraftMineralogy\OS2612\OreSpawn\build\libs\OreSpawn-4.1.1.2601021.jar ``` Other layouts can pass `-PbaseMetalsDeobfJar=` and `-PoreSpawnDeobfJar=`. @@ -85,10 +75,10 @@ Dependencies are never embedded in PowerAdvantage artifacts. Dependency checksums and source commits are recorded in `gradle.properties`. Unknown artifacts fail the dependency audit. The release audit also rejects a player jar that differs from the frozen test kit. Hosted CI requires the Base -Metals pin to be on its upstream `master-1.21.11` branch; a locally tested pin +Metals pin to be on its upstream `master-26.1.2` branch; a locally tested pin does not qualify a hosted build. -Release jars are written to `build/libs`. Forge 61 uses the same mod jar for play +Release jars are written to `build/libs`. Forge 64 uses the same mod jar for play and development; `deobfJar` is kept as a task alias for older build scripts. For Eclipse, import this repository as an existing Gradle project and run: @@ -101,10 +91,10 @@ Refresh the project in Eclipse, allow its Java build to finish, and launch the g Eclipse copies production resources from `build/resources/main`, not directly from `src/main/resources`. This prevents its builder from overwriting the expanded `mods.toml` with raw `${version}` placeholders. After updating this setup, use **Gradle > Refresh Gradle Project**, then **Project > Clean** before relaunching. After editing resources or version properties, run `processResources` or refresh the Gradle project before the Eclipse build. See the [metadata regression notes](docs/1.13/ECLIPSE-METADATA.md). -To prepare a combined client, build the baseSteam and baseElectric `2.2.16.121111` jars, +To prepare a combined client, build the baseSteam and baseElectric `2.2.17.2601021` jars, run `gradlew.bat prepareCombinedEclipseLaunch`, refresh Eclipse, then launch `runClientAllAdvantage.launch`. Inputs must match -`test-rig/profiles/native12111/dev-core.json`; alternative layouts can pass +`test-rig/profiles/native2612/dev-core.json`; alternative layouts can pass `-PsteamAdvantageDeobfJar=` and `-PelectricAdvantageDeobfJar=`. The ordinary `runClient.launch` remains Power-only. Both use the `run` directory; preparing a launch does not copy, open or qualify a save. @@ -113,7 +103,7 @@ preparing a launch does not copy, open or qualify a save. matching their packages. They also read every development runtime jar entry and check its size and CRC to catch damaged cached dependencies. -Keep Eclipse's access-restriction checks enabled and use Java 21 for its project +Keep Eclipse's access-restriction checks enabled and use Java 25 for its project JRE and Minecraft launches. Check visuals, menus and recipe discovery against the same jars used for the automated machine tests. @@ -140,6 +130,6 @@ its presence is not proof that an upgrade is safe. The [legacy migration guide](docs/1.13/MIGRATION.md) describes the earlier protocols. The [Advantage Works 1.20.1 guide](test-rig/NATIVE1201.md) describes the prior -qualification and the checks carried into 1.21.11. +qualification and the checks carried forward into later ports. The historical 1.10 handbook remains on the [1.10.2 branch](https://github.com/MinecraftModDevelopmentMods/PowerAdvantage/blob/master-1.10.2/docs/1.10/README.md). diff --git a/build.gradle b/build.gradle index 572a2254..aadc5d4e 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ plugins { id 'net.minecraftforge.gradle' version '7.0.34' } -apply from: 'gradle/forge61-mavenizer.gradle' +apply from: 'gradle/forge64-mavenizer.gradle' group = project.mod_group version = project.mod_version @@ -46,7 +46,7 @@ ext.release_tag = project.mod_version java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) vendor = JvmVendorSpec.ADOPTIUM } withSourcesJar() @@ -55,11 +55,11 @@ java { tasks.withType(JavaCompile).configureEach { javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) vendor = JvmVendorSpec.ADOPTIUM } - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 options.encoding = 'UTF-8' options.compilerArgs.addAll(['-Xmaxerrs', '1000']) } @@ -134,7 +134,7 @@ sourceSets.main.resources { def forgeRunModClassesDirectory = layout.buildDirectory.dir('forge-run-mod-classes/main') def prepareForgeRunModClasses = tasks.register('prepareForgeRunModClasses', Sync) { group = 'ide' - description = 'Merges classes and processed resources for Forge 1.21.11 launches.' + description = 'Merges classes and processed resources for Forge 26.1.2 launches.' dependsOn tasks.named('classes') from sourceSets.main.output into forgeRunModClassesDirectory @@ -170,11 +170,11 @@ repositories { // build to a drive letter. CI and other layouts can override either path. def baseMetalsDeobfJar = file(providers.gradleProperty('baseMetalsJar') .orElse(providers.gradleProperty('baseMetalsDeobfJar')) - .getOrElse('../../../BaseMetals/.verification-basemetals12111/Advantage shared hook/build/libs/' + .getOrElse('../../../BaseMetals/BaseMetals 2612/BaseMetals/build/libs/' + "BaseMetals-${project.basemetals_version}.jar")) def oreSpawnDeobfJar = file(providers.gradleProperty('oreSpawnJar') .orElse(providers.gradleProperty('oreSpawnDeobfJar')) - .getOrElse('../../../MinecraftMineralogy/OS12111/OreSpawn/build/libs/' + .getOrElse('../../../MinecraftMineralogy/OS2612/OreSpawn/build/libs/' + "OreSpawn-${project.orespawn_version}.jar")) dependencies { @@ -183,6 +183,8 @@ dependencies { compileOnly files(baseMetalsDeobfJar, oreSpawnDeobfJar) runtimeOnly files(baseMetalsDeobfJar, oreSpawnDeobfJar) + // Exercise the real migration callback and typed OreSpawn provider boundary. + testCompileOnly files(baseMetalsDeobfJar, oreSpawnDeobfJar) testImplementation 'junit:junit:4.13.2' } @@ -257,7 +259,7 @@ tasks.named('jar', Jar) { } } -// Forge 61 uses the official names at runtime, so the same jar serves both uses. +// Forge 64 uses the official names at runtime, so the same jar serves both uses. def releaseJar = tasks.named('jar', Jar) tasks.register('deobfJar') { @@ -310,24 +312,24 @@ tasks.register('verifyReleaseConfiguration') { description = 'Validates release identity, target metadata, and required dependencies.' doLast { def pack = new JsonSlurper().parse(file('src/main/resources/pack.mcmeta')).pack - if (pack.min_format != [94, 1] || pack.max_format != [94, 1] + if (pack.min_format != [101, 1] || pack.max_format != [101, 1] || !file('src/main/resources/META-INF/mods.toml').text.contains('showAsResourcePack=false')) { - throw new GradleException('Forge 61 requires data format 94.1 and its native hidden-client resource pack') + throw new GradleException('Forge 64 requires data format 101.1 and its native hidden-client resource pack') } - if (project.mod_version != '2.4.15.121111') { + if (project.mod_version != '2.4.16.2601021') { throw new GradleException("Unexpected PowerAdvantage release version: ${project.mod_version}") } - if (project.minecraft_version != '1.21.11' - || project.forge_version != '61.1.0' + if (project.minecraft_version != '26.1.2' + || project.forge_version != '64.1.5' || project.mapping_channel != 'official' - || project.mapping_version != '1.21.11' - || project.mcp_version != '20251223.124241') { + || project.mapping_version != '26.1.2' + || project.mcp_version != '20260409.101008') { throw new GradleException('Unexpected Minecraft, Forge, or mappings target') } if (project.loader_name != 'forge' || project.loader_code != '1' - || project.java_version != '21' - || project.java_toolchain_version != '21.0.7+6' - || project.gradle_java_version != '21') { + || project.java_version != '25' + || project.java_toolchain_version != '25.0.3+9' + || project.gradle_java_version != '25') { throw new GradleException('Unexpected release dispatcher target metadata') } if (project.mod_id != 'basepower' @@ -338,19 +340,19 @@ tasks.register('verifyReleaseConfiguration') { } if (project.cf_requirements != 'base-metals,mmd-orespawn' || project.cf_optionals != 'additional-loot-tables' - || project.cf_versions != '1.21.11') { + || project.cf_versions != '26.1.2') { throw new GradleException('Unexpected CurseForge dependency or game-version metadata') } - if (project.basemetals_version != '3.1.2.121111' - || project.orespawn_version != '4.1.0.121111' + if (project.basemetals_version != '3.1.2.2601021' + || project.orespawn_version != '4.1.1.2601021' || !(project.basemetals_source_commit ==~ /[a-f0-9]{40}/) - || project.orespawn_source_commit != 'ecad75b24309a344f712d898d717c6a8823fc9f2') { + || project.orespawn_source_commit != '101fd12b598934c353e417c57edc5c2bbefd5f53') { throw new GradleException('Unexpected Base Metals or OreSpawn dependency identity') } String mainSource = file( 'src/main/java/zone/moddev/mc/basepower/PowerAdvantage.java').getText('UTF-8') if (!mainSource.contains('MODID = "basepower"') - || !mainSource.contains('VERSION = "2.4.15.121111"') + || !mainSource.contains('VERSION = "2.4.16.2601021"') || !mainSource.contains('@Mod(PowerAdvantage.MODID)')) { throw new GradleException('PowerAdvantage source identity changed') } @@ -597,8 +599,8 @@ tasks.register('verifyReleaseArtifacts') { } } int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) - if (major != 65) { - throw new GradleException("${entry.name} uses Java class major ${major}, expected 65") + if (major != 69) { + throw new GradleException("${entry.name} uses Java class major ${major}, expected 69") } } } @@ -800,6 +802,8 @@ tasks.register('configureEclipseBuildship') { [ 'eclipse.preferences.version' : '1', 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', + 'connection.java.home' : javaToolchains.compilerFor(java.toolchain).get() + .metadata.installationPath.asFile.canonicalPath, 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, 'connection.project.dir' : '', 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, @@ -909,7 +913,8 @@ tasks.register('isolateEclipseProductionRuns') { classpath.children().addAll(runtimeIndex, runtimeEntries) contents = groovy.xml.XmlUtil.serialize(launchXml) - ['--cache', '--metadata', '--to-srg', '--to-obf'].each { option -> + // Official runtime names no longer need Slime's old remapping files. + ['--cache', '--metadata'].each { option -> String prefix = option + ' ' int valueStart = contents.indexOf(prefix) if (valueStart < 0) { @@ -926,6 +931,34 @@ tasks.register('isolateEclipseProductionRuns') { + '"' + value + '"' + contents.substring(valueEnd)) } } + def resolvedLaunch = parser.parseText(contents) + def programAttribute = resolvedLaunch.stringAttribute.find { + it.@key == 'org.eclipse.jdt.launching.PROGRAM_ARGUMENTS' + } + String program = programAttribute.@value + if (program.contains('--to-srg') || program.contains('--to-obf')) { + throw new GradleException("${name} must not remap the official Forge 64 runtime") + } + if (name == 'runClient.launch') { + File nativeJar = productionRuntime.find { + it.name == "forge-${project.minecraft_version}-${project.forge_version}.jar" + } + if (nativeJar == null) throw new GradleException('Missing native client metadata input') + File metadata = new File(nativeJar.parentFile, + "forge-${project.minecraft_version}-${project.forge_version}-metadata.zip") + def assetIndex + new ZipFile(metadata).withCloseable { zip -> + assetIndex = new JsonSlurper().parse(zip.getInputStream( + zip.getEntry('minecraft/version.json'))).assetIndex + } + File assets = file(providers.gradleProperty('minecraftAssetsDir').getOrElse( + new File(System.getenv('APPDATA') ?: System.getProperty('user.home'), + '.minecraft/assets').path)) + // Metadata generation needs no asset download. The client gate verifies the actual files. + programAttribute.@value = program.replace('{asset_index}', assetIndex.id.toString()) + .replace('{assets_root}', '"' + assets.canonicalPath + '"') + } + contents = groovy.xml.XmlUtil.serialize(resolvedLaunch) launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') } } @@ -968,6 +1001,10 @@ tasks.register('verifyEclipseProductionClasspath') { != gradle.gradleUserHomeDir.canonicalFile) { throw new GradleException('Eclipse Buildship does not use this wrapper and Gradle home') } + File javaHome = javaToolchains.compilerFor(java.toolchain).get().metadata.installationPath.asFile + if (file(preferences.getProperty('connection.java.home', '')).canonicalFile != javaHome.canonicalFile) { + throw new GradleException('Eclipse Buildship must use the pinned Java 25 installation') + } String projectMetadata = file('.project').getText('UTF-8') if (!projectMetadata.contains('org.eclipse.buildship.core.gradleprojectnature') || !projectMetadata.contains('org.eclipse.buildship.core.gradleprojectbuilder')) { @@ -1017,12 +1054,19 @@ tasks.register('verifyEclipseProductionClasspath') { it.@key.text() == 'org.eclipse.jdt.launching.PROGRAM_ARGUMENTS' }.@value.text() String target = name == 'runClient.launch' ? 'forge_userdev_client' : 'forge_userdev_server' + String nativeArguments = program.contains(' -- ') ? program.split(' -- ', 2)[1] : '' + def launchTargets = nativeArguments =~ /(?:^|\s)--launchTarget\s+([^\s]+)(?=\s|$)/ if (main != 'net.minecraftforge.launcher.Main' || !program.startsWith('--main net.minecraftforge.bootstrap.ForgeBootstrap ') - || !program.contains(" -- --launchTarget ${target} ")) { - throw new GradleException("${name} does not start the Forge 52 bootstrap") + || launchTargets.count != 1 || launchTargets[0][1] != target) { + throw new GradleException("${name} does not start the Forge 64 bootstrap") + } + if (program.contains('--to-srg') || program.contains('--to-obf') + || program.contains('{asset_index}') || program.contains('{assets_root}') + || !contents.contains('StandardVMType/JavaSE-25')) { + throw new GradleException("${name} has obsolete mappings, unresolved assets or the wrong Java runtime") } - ['--cache', '--metadata', '--to-srg', '--to-obf'].each { option -> + ['--cache', '--metadata'].each { option -> def argument = program =~ /(?:^|\s)${option}\s+(?:"([^"]+)"|([^"\s]+))(?=\s+--|$)/ if (!argument.find()) { throw new GradleException("${name} must keep ${option} as one Slime Launcher argument") @@ -1057,9 +1101,11 @@ tasks.register('verifyEclipseProductionClasspath') { } List bootstrapArtifacts = [ "forge-${project.minecraft_version}-${project.forge_version}", - "client-extra-${project.minecraft_version}", - "mappings_${project.mapping_channel}-${project.minecraft_version}" + "client-extra-${project.minecraft_version}" ] + if (contents.contains('mappings_')) { + throw new GradleException("${name} includes a mapping archive in an official-name runtime") + } List bootstrapPositions = bootstrapArtifacts.collect { artifact -> contents.indexOf(artifact) } @@ -1085,6 +1131,8 @@ tasks.register('verifyModelAssets') { File blockstateRoot = new File(resourceRoot, "assets/${project.mod_id}/blockstates") inputs.dir(modelRoot) inputs.dir(blockstateRoot) + inputs.files('src/main/resources/assets/minecraft/atlases/blocks.json', + 'src/main/resources/assets/minecraft/atlases/items.json') doLast { Set vanillaAssets = [] as Set @@ -1095,7 +1143,29 @@ tasks.register('verifyModelAssets') { zip.entries().each { entry -> vanillaAssets.add(entry.name) } } } - def atlas = new JsonSlurper().parse(new File(resourceRoot, 'assets/minecraft/atlases/blocks.json')) + Set registeredSprites = [] as Set + ['blocks', 'items'].each { String atlasName -> + File atlasFile = new File(resourceRoot, "assets/minecraft/atlases/${atlasName}.json") + def atlas = new JsonSlurper().parse(atlasFile) + if (atlas.keySet() != ['sources'] as Set || !(atlas.sources instanceof List) || atlas.sources.isEmpty()) { + throw new GradleException("${atlasFile} must declare nonempty explicit sprite sources") + } + atlas.sources.each { source -> + if (!(source instanceof Map) || source.keySet() != ['type', 'resource'] as Set + || source.type != 'minecraft:single' || !(source.resource instanceof String) + || !source.resource.startsWith("${project.mod_id}:")) { + throw new GradleException("${atlasFile} must only register this mod's native single sprites") + } + String sprite = source.resource + if (!registeredSprites.add(sprite)) { + throw new GradleException("Duplicate sprite across native atlases: ${sprite}") + } + String spritePath = sprite.substring(sprite.indexOf(':') + 1) + if (!new File(resourceRoot, "assets/${project.mod_id}/textures/${spritePath}.png").isFile()) { + throw new GradleException("${atlasFile} registers missing sprite ${sprite}") + } + } + } def verifyReference = { String reference, String assetType, String sourcePath -> if (reference.startsWith('#')) return int separator = reference.indexOf(':') @@ -1115,9 +1185,9 @@ tasks.register('verifyModelAssets') { "assets/${namespace}/${assetType}/${path}.json") if (assetType == 'textures') { target = new File(resourceRoot, "assets/${namespace}/textures/${path}.png") - if (!atlas.sources.any { it.type == 'directory' && it.prefix == it.source + '/' - && path.startsWith(it.prefix as String) }) { - throw new GradleException("${sourcePath} references texture ${reference} outside the block atlas") + // Native 26 materials identify a sprite, not a fixed block/item atlas. + if (!registeredSprites.contains("${namespace}:${path}".toString())) { + throw new GradleException("${sourcePath} references unregistered native sprite ${reference}") } } if (!target.isFile()) { @@ -1263,7 +1333,7 @@ tasks.register('prepareCombinedEclipseLaunch') { dependsOn tasks.named('deobfJar') doLast { File inputProfile = file(providers.gradleProperty('advantageDevelopmentProfile').getOrElse( - 'test-rig/profiles/native12111/dev-core.json')) + 'test-rig/profiles/native2612/dev-core.json')) def profile = new JsonSlurper().parse(inputProfile) if (profile.mode != 'development' || profile.minecraftVersion != project.minecraft_version || profile.forgeVersion != project.forge_version) { diff --git a/docs/26.1.2/PORT-STATUS.md b/docs/26.1.2/PORT-STATUS.md new file mode 100644 index 00000000..da43cb41 --- /dev/null +++ b/docs/26.1.2/PORT-STATUS.md @@ -0,0 +1,111 @@ +# Minecraft 26.1.2 Port + +The current candidates have completed their +[automated qualification](QUALIFICATION.md), and manual playtesting was accepted +on 12 November 2026. Publication remains a separate step. Earlier findings below retain their +original scope and candidate bindings. + +## Target +- basePower `2.4.16.2601021`, Forge `64.1.5` and Java `25.0.3+9`. +- baseSteam and baseElectric `2.2.17.2601021`. +- Base Metals `3.1.2.2601021`, with the local shared migration hook, and + OreSpawn `4.1.1.2601021`. Exact source commits and hashes are in + `gradle.properties`. The Base Metals patch is not public yet. + +## Checks So Far +The current Power code passes all 357 unit tests under both Gradle and a fresh +Eclipse compiler build. The earlier candidate06 figures below are retained +history, not qualification of the current jars. + +The broader checks found two more world-generation migration cases. A new +world can have a pack-supplied OreSpawn profile before it has a `level.dat`; +that bypassed the existing-world hook until the next start. Power now prepares +that profile synchronously before OreSpawn's server-start listener. The early +hook used for existing worlds remains in place. The regression failed before +the repair and passes afterwards. Fresh server runs using the released 1.10 +and 1.12 profiles now both pass first start and a full process restart. These +are scoped Power-hook checks, not final combined qualification. + +The retained-profile checks also found ten new Base Metals ore rules eligible +alongside their saved legacy equivalents. This is not a harmless warning or a +reason to change the expected results. A local Base Metals repair needs +separate approval. Its failing regression tests are retained; no production +repair has been made yet. Final combined qualification and endurance are on +hold until this is resolved. Nothing here is a manual-testing-ready claim. + +Candidate06 passes the full build and release audits with the new Forge API +and exact local dependency pins. All 311 unit tests pass under both Gradle and Eclipse's +compiler. The Eclipse check also rejects an incomplete test inventory: a +filtered run cannot count as the full suite. Another 37 focused resource, +trade and GUI extraction checks pass. Earlier failed runs are retained. + +The first combined machine run found an old obfuscated field lookup in +container-lock handling. The fix uses the native lock API for single chests +and checks both halves of double chests. Six new regression tests include +locked, unlocked, wrapped and nested inventories; five failed before the fix. +The subsequent packaged machine and restart checks pass for candidate06. + +A fuel-helper regression also showed that consuming the last lava bucket +lost its empty bucket. The helper now captures the native remainder before +emptying the stack. Ordinary fuels, larger stacks, empty and legacy null +inputs, and explicit post-burn rules keep their existing behaviour. The +failing bucket test and its passing rerun are retained separately. + +The wider old-world tests also caught lost musket enchantments in dropped +items and chest minecarts. Base Metals could convert them before Power had +loaded the saved enchantment index. The shared hook now supplies an isolated +registry snapshot before conversion, regardless of hook order, and clears +old context when another world is selected. Both new Power tests and both +Base Metals callback tests failed before the repair and pass afterwards. +The rebuilt family passes the live migration and both actual hook-order checks +for candidate06, including stopped entity data and saved enchantments. + +An actual client run exposed a separate atlas-registration problem: broad +legacy texture directories also pull in another mod's directly rendered +laser texture. Native 26 rejects its mip allocation before the title screen. +Each mod now registers only its own atlas sprites; candidate06 gets past the +native mip4 upload without lowering the mipmap setting. Rendered-client +qualification remains open. The test client must first enter its copied world +through Minecraft's experimental-world warning, then prove its offscreen +projection with actual GPU pixels before the GUI comparisons can count. + +Candidate06 also passes 132 machine jobs, the walkable rigs, fluids and survival +bucket handling, all three recipe modes, network unloading, extended moving +drills, original/released-world machine upgrades, and two 30-minute endurance +worlds followed by full restarts and an independent saved-state replay. +Visual and interactive checks are separate from those server results. + +The broader log review caught another real upgrade problem: retained OreSpawn +profiles still name old Advantage output blocks. Finding ore from a new default +does not prove those saved rules work. An output-only migration is being added +before OreSpawn loads global and selected-world profiles. It keeps rule IDs, +rates, heights, selectors and enabled choices, with backups and migration +reports. Its new regression checks reject lost settings, invalid outputs and +duplicate defaults. This requires a new frozen candidate and full rerun; the +earlier passing results are not being relabelled for the new jars. + +Vanilla itself changes wolf health during some older-world upgrades. The raw +comparison retains that difference, with a separate native-only control; the +other actor fields, ownership and UUIDs remain strict checks. Optional +Mineralogy-only lanes are marked skipped, not passed without that mod. + +The port keeps registry and NBT identities. It replaces the removed villager +trade event with native trade data and uses the current GUI and fluid-model +APIs. Saved world and player paths change in Minecraft 26, so the migration +suite must prove the converted files and machine state after a full restart. + +## Remaining Gates +- Freeze and qualify the repaired OreSpawn-profile candidate. +- Repeat the complete release and Eclipse compiler checks, reproducibility, + fresh-cache and path-with-spaces builds against that candidate. +- Connected-machine operation, recipe progression, fluids, networks, moving drills, + chunk unloading and restart checks against the same candidate jars. +- Original Advantage and released base* world, player and entity upgrades, + additional dimensions, later chunks and both actual migration-hook orders. +- Rendered models, all machine GUIs, captions, gauges, recipe discovery and login. +- Two pre-sealed 30-minute endurance worlds, cold restart and independent exact + saved-state replay. +- Manual testing, followed by separately authorised publication. + +Earlier [1.21.11 qualification](../1.21.11/QUALIFICATION.md) is retained unchanged +as historical evidence. It does not qualify this build. diff --git a/docs/26.1.2/QUALIFICATION.md b/docs/26.1.2/QUALIFICATION.md new file mode 100644 index 00000000..3610fd77 --- /dev/null +++ b/docs/26.1.2/QUALIFICATION.md @@ -0,0 +1,71 @@ +# Minecraft 26.1.2 Test Results + +The current basePower, baseSteam and baseElectric candidates have completed +their automated checks and manual playtesting. Publication is being prepared +separately; a passing test record does not mean a release is already public. + +## Tested Versions + +| Mod | Version | Player Jar SHA-256 | +| --- | --- | --- | +| basePower | 2.4.16.2601021 | `23ABCC1FEFDAA6988BF709437EB165C8948C757E773ADAB738092EB93CA8D2B0` | +| baseSteam | 2.2.17.2601021 | `4A4E7D8C3704DCF17F46EBB55A7A908A6E1B547FCAB93E3B6782EE32E49301CB` | +| baseElectric | 2.2.17.2601021 | `E2929ED56B4CEE1AD50B6B0D2BF1AF000AA15F2C9D66E5DFB3679B53FBDED9FC` | + +These checks used Forge 64.1.5, Temurin 25.0.3+9, the local Base Metals +3.1.2.2601021 migration-hook build, and published OreSpawn 4.1.1.2601021 +(CurseForge file 9572926). Base Metals and OreSpawn were installed throughout. +The Base Metals hook build is still local; an older public jar is not a +substitute for this test kit. + +OreSpawn's player jar was downloaded anonymously and verified as +`810284ECB32D5249381F246498CEFA57E70FFF7CCC40110F799FC276CBC42BF7`, +from source commit `101fd12b598934c353e417c57edc5c2bbefd5f53`. + +## Completed Checks + +- Release, dependency, Java bytecode, resource, checksum and Eclipse classpath audits for all three mods. +- Independent clean-cache builds in paths containing spaces, with matching distributable jars. +- Power's 357 unit tests and fresh Eclipse compiler run; Steam's 276 tests and Eclipse run; Electric's 117 unit tests, 28 native checks and Eclipse run. +- The 384-test harness, 267 Python adapter tests and 24 worldless client-probe tests. +- All 21 walkable works bays, 12 drill witnesses, capability/automation checks and 132 finite machine jobs, including full process restarts and stopped-save comparisons. +- Harvesting, chunk lifecycle, physical partial-network unloading, bridge reconnection and extended moving-drill scenarios. +- Recipe discovery and manufacturing progression in all three recipe modes, with rubber enabled and disabled; six oil lanes across two cold restarts. +- Fresh generation, legacy imports, and original 1.10 plus released 1.10/1.12 generation profiles in new chunks. Saved choices remain authoritative, and replacement rules do not become duplicate active placements. +- Eight operating machine-upgrade routes: original Cyano 1.10, released base* 1.10 and 1.12, and retained 1.19.4, 1.20.1, 1.20.6, 1.21.1 and 1.21.11 saves. +- Nine actor/dimension routes, including Nether, End and chained upgrades, with stopped entity/player data checks. Both actual Power-first and Base-Metals-first migration-hook orders were exercised separately. +- A real graphical client: 39 machine screens, 351 caption checks, fluid gauges, recipe-book cells, model/resource checks, reloads and 1,247 fenced GPU frames, including negative controls. +- Two measured 30-minute endurance worlds: one fresh and one upgraded from the original 1.10 fixture. Both saved, stopped, restarted and resumed processing. Independent replay checked all 66 checkpoints and raw stopped saves; minimum interval throughput remained above 19.5 ticks per second. + +The resumed run did not require another Advantage gameplay repair. The changes +were dependency pins, the required OreSpawn minimum, and test/setup corrections. +Failed attempts remain separate from the new passing results. + +## Limits And Manual Checks + +Six dimension fixtures retain `PASS_WITH_NATIVE_DIFFERENCE`, not a rewritten +strict pass. Their only differences are independently controlled native +Minecraft wolf health/max-health scaling. Original strict comparisons remain +available; all other fields match. + +Three Mineralogy-only oil assertions are explicitly skipped because that +optional mod is not in the six-jar test kit. This does not qualify an entire +modpack or unrelated mods' saved data. + +Check the normal play experience, particularly turbine orientation/spin, +weapon handling, and machines in a copy of a real world. Automated geometry +checks do not replace the remaining world-dependent turbine visual check. + +## Local Evidence + +The immutable family profile is `native2612-local-candidate-12.json`, SHA-256 +`C42AAE0ED4B1E56854991E45FD67B495E1CA16AC1C639E894FD190414D2E3CC8`. +The local evidence pack is under +`D:/PowerAdvantage/_verification/base-2612-port-20261112/resume-orespawn411-20261112/`. +It contains original logs, failed setup attempts, source/input hashes, raw +save comparisons, client frames, the genuine pre-run endurance seal and +independent stopped-save replay. Older candidate results were not relabelled. + +Manual testing was accepted on 12 November 2026. Source delivery and publication +follow the dependency order: Base Metals, basePower, baseSteam, then baseElectric. +The exact test jars and original automated evidence remain unchanged. diff --git a/gradle.properties b/gradle.properties index 5ba1ec41..54e4f74b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,45 +5,47 @@ org.gradle.caching=true org.gradle.parallel=false net.minecraftforge.gradle.merge-source-sets=false -minecraft_version=1.21.11 -minecraft_version_range=[1.21.11] -forge_version=61.1.0 -forge_installer_sha256=8BD1724EAEAF26F51DACFB7FDC46D79E9B640705B70D5F0019ED38907145EB48 -forge_version_range=[61.1.0,62) -loader_version_range=[61,) +minecraft_version=26.1.2 +minecraft_version_range=[26.1.2] +forge_version=64.1.5 +forge_installer_sha256=6E01323837051176762BD466340A601DA122F179FE6A00AC616E05429AE07648 +forge_version_range=[64.1.5,65) +loader_version_range=[64,65) mapping_channel=official -mapping_version=1.21.11 -mcp_version=20251223.124241 +mapping_version=26.1.2 +mcp_version=20260409.101008 loader_name=forge loader_code=1 -java_version=21 -java_toolchain_version=21.0.7+6 -java_setup_version=21.0.7+6.0.LTS -gradle_java_version=21 +java_version=25 +java_toolchain_version=25.0.3+9 +java_setup_version=25.0.3+9.0.LTS +gradle_java_version=25 curseforge_project_id=1780140 mod_id=basepower mod_name=basePower mod_license=LGPL-3.0-only -mod_version=2.4.15.121111 -release_player_sha256=C98D2CF163BE2480D9AFB0023BBD2D92D401AE0D50BEF73191235BF3F83774A7 -basesteam_version=2.2.16.121111 -baseelectric_version=2.2.16.121111 +mod_version=2.4.16.2601021 +# Frozen build candidate, not a claim of completed gameplay qualification or publication. +release_player_sha256=23ABCC1FEFDAA6988BF709437EB165C8948C757E773ADAB738092EB93CA8D2B0 +basesteam_version=2.2.17.2601021 +baseelectric_version=2.2.17.2601021 mod_group=zone.moddev.mc.basepower mod_authors=DrCyano (aka Cyanobacterium) mod_description=API and shared machines for pressure, electricity, quantum power, fluids, and item transport. cf_requirements=base-metals,mmd-orespawn cf_optionals=additional-loot-tables -cf_versions=1.21.11 +cf_versions=26.1.2 -basemetals_version=3.1.2.121111 -basemetals_source_commit=616d527e50e4bf0137eeb9c44fa586e34a0452a0 +basemetals_version=3.1.2.2601021 +basemetals_source_commit=331fa46914f854db71a8c33500cc559a8878b7d3 basemetals_curse_project_id=240967 -basemetals_deobf_sha256=598D3FB0FF956EBCC67FE477A01CE393C75D0013AF0CE00A79E2072B63E9F2BA +basemetals_deobf_sha256=6786238835D4D8A653C769F3B0E61D226959146300E17523799CFDF17540FD19 -orespawn_version=4.1.0.121111 -orespawn_source_commit=ecad75b24309a344f712d898d717c6a8823fc9f2 +orespawn_version=4.1.1.2601021 +orespawn_source_commit=101fd12b598934c353e417c57edc5c2bbefd5f53 orespawn_curse_project_id=245586 -orespawn_deobf_sha256=DE39929057288CEA110196C9E9D708E129B80BC360A2E53976337FFA6A84A67B +orespawn_public_file_id=9572926 +orespawn_deobf_sha256=810284ECB32D5249381F246498CEFA57E70FFF7CCC40110F799FC276CBC42BF7 diff --git a/gradle/forge61-mavenizer.gradle b/gradle/forge61-mavenizer.gradle deleted file mode 100644 index 072e418f..00000000 --- a/gradle/forge61-mavenizer.gradle +++ /dev/null @@ -1,29 +0,0 @@ -import java.security.MessageDigest -import org.gradle.jvm.toolchain.JvmVendorSpec - -// Use the same checksum-sealed source preparation tool as OreSpawn on Forge 61. -def tool = 'ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar' -def defaultJar = project.extensions.extraProperties.has('forge61MavenizerDefault') - ? project.forge61MavenizerDefault - : "../../../MinecraftMineralogy/OS12111/OreSpawn/${tool}" -def fixture = file(providers.gradleProperty('forge61MavenizerJar').getOrElse(defaultJar)) -def expected = 'B36CA046DB72E30F27BD38E02B4AE34274ADB51AE1D65BAF9E1D07763755682E' -if (!fixture.isFile() || MessageDigest.getInstance('SHA-256').digest(fixture.bytes) - .encodeHex().toString().toUpperCase(Locale.ROOT) != expected) { - throw new GradleException("Missing or altered Forge 61 Mavenizer fixture: ${fixture}") -} -def rules = new File(fixture.parentFile, 'minecraft-source-compatibility.json') -if (!rules.isFile() || MessageDigest.getInstance('SHA-256').digest(rules.bytes) - .encodeHex().toString().toUpperCase(Locale.ROOT) != - '88F2EE4C6B7C903EB1C7D693C0BEEFC0EFC601C1023FB197F7CBA187B1142DB7') { - throw new GradleException("Missing or altered Forge 61 source-rule manifest: ${rules}") -} - -fgtools.configure('mavenizer') { - classpath.from(fixture) - mainClass.set('net.minecraftforge.mcmaven.cli.Main') - javaLauncher.set(javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(25) - vendor = JvmVendorSpec.ADOPTIUM - }) -} diff --git a/gradle/forge64-mavenizer.gradle b/gradle/forge64-mavenizer.gradle new file mode 100644 index 00000000..375f853c --- /dev/null +++ b/gradle/forge64-mavenizer.gradle @@ -0,0 +1,40 @@ +import java.security.MessageDigest +import org.gradle.jvm.toolchain.JvmVendorSpec + +// Keep the preparation tool and its source-rule/license evidence together. +// A different checkout layout can override the jar path without editing this file. +def tool = 'ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar' +def defaultJar = project.extensions.extraProperties.has('forge64MavenizerDefault') + ? project.forge64MavenizerDefault + : "../../../MinecraftMineralogy/OS2612/OreSpawn/${tool}" +def fixture = file(providers.gradleProperty('forge64MavenizerJar').getOrElse(defaultJar)) +def checksums = [ + (fixture.name): 'BB0DACB3DEB2A711CA67DD402D5FDAB5A9847C37C7B9B596DEC7CF37378FA085', + 'minecraft-source-compatibility.json': '21D1F5984E735BCEA1B1EFB9F965655796B39538FC91AAED1C3474004710423D', + 'minecraft-mavenizer-0.5.21-orespawn-compat.patch': '7C8EC895E79DF31AFD2CA3B505B43573C7052718A24050832189AAA2D3CC6137', + 'LICENSE-MAVENIZER.txt': '20C17D8B8C48A600800DFD14F95D5CB9FF47066A9641DDEAB48DC54AEC96E331', + 'README.md': '7686880D2B0F6BD6ACAFA4F0AB7585189767813174BC36DB675FEC0142440A77' +] +checksums.each { name, expected -> + File input = new File(fixture.parentFile, name) + if (!input.isFile() || MessageDigest.getInstance('SHA-256').digest(input.bytes) + .encodeHex().toString().toUpperCase(Locale.ROOT) != expected) { + throw new GradleException("Missing or altered Forge 64 Mavenizer fixture: ${input}") + } +} + +fgtools.configure('mavenizer') { + classpath.from(fixture) + mainClass.set('net.minecraftforge.mcmaven.cli.Main') + javaLauncher.set(javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.ADOPTIUM + }) +} + +fgtools.configure('slimelauncher') { + javaLauncher.set(javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.ADOPTIUM + }) +} diff --git a/src/main/java/cyano/poweradvantage/api/migration/LegacyOreSpawnProfileMigration.java b/src/main/java/cyano/poweradvantage/api/migration/LegacyOreSpawnProfileMigration.java new file mode 100644 index 00000000..19bb186d --- /dev/null +++ b/src/main/java/cyano/poweradvantage/api/migration/LegacyOreSpawnProfileMigration.java @@ -0,0 +1,491 @@ +package cyano.poweradvantage.api.migration; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.Strictness; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import java.io.IOException; +import java.io.StringReader; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +/** Output-only migration; never renames rule IDs, provider manifests or policy references. */ +public final class LegacyOreSpawnProfileMigration { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + private static final String FORMAT = "advantage-orespawn-profile-outputs-v1"; + private static final String ORDINARY = "orespawn:all_except_nether_end"; + private static final int MAX_BYTES = 16 * 1024 * 1024; + private static final List WRAPPERS = List.of("profile", "geology", "worldgen", "geology_profile", "worldgen_profile"); + + private LegacyOreSpawnProfileMigration() {} + + /** Pure result. A refused plan exposes the original JSON, not a partially rewritten profile. */ + public static final class Plan { + private final JsonObject json; + private final List changes; + private final List problems; + private final List warnings; + + private Plan(JsonObject json, List changes, List problems, List warnings) { + this.json = json.deepCopy(); + this.changes = List.copyOf(changes); + this.problems = List.copyOf(problems); + this.warnings = List.copyOf(warnings); + } + + public JsonObject getJson() { return json.deepCopy(); } + public List getChanges() { return changes; } + public List getProblems() { return problems; } + public List getWarnings() { return warnings; } + public boolean isRefused() { return !problems.isEmpty(); } + public boolean isChanged() { return !isRefused() && !changes.isEmpty(); } + } + + public enum Status { MISSING, UNCHANGED, REFUSED, MIGRATED } + + public record Result(Status status, Path backupFile, Path reportFile, Plan plan) { + public boolean wasMigrated() { return status == Status.MIGRATED; } + } + + /** + * Accepts flat global/world profiles (including native schemas 8/7), provider overrides, + * and explicit profile wrappers. Defaults are one full owned provider JSON declaration. + * The caller supplies constructor-safe target validation; this class loads no registries. + */ + public static Plan plan(JsonObject source, JsonObject ownedDefaults, Predicate targetExists) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(ownedDefaults, "ownedDefaults"); + Objects.requireNonNull(targetExists, "targetExists"); + JsonObject copy = source.deepCopy(); + List changes = new ArrayList<>(), problems = new ArrayList<>(), warnings = new ArrayList<>(); + String owner = text(ownedDefaults.get("provider_modid")); + if (!"basepower".equals(owner) && !"baseelectric".equals(owner)) { + problems.add("Defaults must belong to basepower or baseelectric"); + } + visit(copy, "$", ownedDefaults, owner, targetExists, changes, problems, warnings, 0); + return new Plan(problems.isEmpty() ? copy : source, changes, problems, warnings); + } + + private record Rule(String section, String id, JsonObject json, Set outputs, + Set domains, boolean legacy, boolean retained) {} + + private static void visit(JsonObject root, String path, JsonObject defaults, String owner, + Predicate exists, List changes, List problems, List warnings, int depth) { + if (depth > 8) { problems.add(path + ": too many profile wrappers"); return; } + List rules = new ArrayList<>(); + for (String section : List.of("ores", "fluid_deposits")) { + JsonObject table = object(root, section, path, problems); + if (table == null) continue; + for (Map.Entry entry : table.entrySet()) { + if (!entry.getValue().isJsonObject()) { problems.add(path + "/" + section + "/" + entry.getKey() + ": expected rule object"); continue; } + JsonObject rule = entry.getValue().getAsJsonObject(); + String at = path + "/" + section + "/" + entry.getKey(); + boolean legacy = legacyId(entry.getKey()) || legacyId(text(rule.get("block"))) + || "poweradvantage".equals(text(rule.get("source_provider"))) + || "electricadvantage".equals(text(rule.get("source_provider"))); + JsonElement declared = defaults.get(section); + boolean currentDefault = declared != null && declared.isJsonObject() + && declared.getAsJsonObject().has(entry.getKey()); + Set outputs = outputs(rule, entry.getKey(), at, owner, exists, changes, problems); + rules.add(new Rule(section, entry.getKey(), rule, outputs, domains(root, rule, section, outputs, at, problems), + legacy && !currentDefault, legacy)); + } + } + JsonObject oil = object(root, "oil", path, problems); + if (oil != null) { + boolean legacy = legacyId(text(oil.get("block"))); + Set outputs = outputs(oil, "", path + "/oil", owner, exists, changes, problems); + Set domains = enabled(root, "place_crude_oil", true, path, problems) + && enabled(root, "place_fluid_deposits", true, path, problems) + && enabled(oil, "enabled", true, path + "/oil", problems) && positive(oil, path + "/oil", problems) + ? Set.of("minecraft:overworld") : Set.of(); + rules.add(new Rule("oil", "oil", oil, outputs, domains, legacy, legacy)); + } + JsonObject policies = object(root, "ore_source_policies", path, problems); + if (policies != null) { + for (Map.Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonElement candidates = entry.getValue().getAsJsonObject().get("candidates"); + if (candidates != null && candidates.isJsonArray()) { + int index = 0; + for (JsonElement candidate : candidates.getAsJsonArray()) { + if (candidate.isJsonObject()) rewrite(candidate.getAsJsonObject(), "registry_id", + path + "/ore_source_policies/" + entry.getKey() + "/candidates/" + index, owner, exists, changes, problems); + index++; + } + } + } + } + for (Rule legacy : rules) { + if (!legacy.legacy || legacy.domains.isEmpty()) continue; + for (Rule current : rules) { + if (current.legacy || current.domains.isEmpty() || !overlap(legacy.domains, current.domains) + || legacy.outputs.stream().noneMatch(current.outputs::contains)) continue; + String comparison = legacy.section.equals(current.section) && legacy.json.equals(current.json) + ? "equivalent" : "conflicting"; + problems.add(path + ": ambiguous " + comparison + " active output collision: " + + legacy.section + "/" + legacy.id + " and " + current.section + "/" + current.id); + } + } + String provider = text(root.get("provider_modid")); + if (owner != null && (provider == null || provider.equals(owner))) { + seed(root, path, defaults, owner, rules, exists, changes, problems); + } else if (owner != null && provider != null && !provider.equals(owner) && !rules.isEmpty()) { + warnings.add(path + ": provider identity retained; no foreign-namespace default guards inserted into " + provider); + } + for (String wrapper : WRAPPERS) { + JsonElement nested = root.get(wrapper); + if (nested != null && nested.isJsonObject() && profileShape(nested.getAsJsonObject())) { + visit(nested.getAsJsonObject(), path + "/" + wrapper, defaults, owner, exists, changes, problems, warnings, depth + 1); + } + } + } + + private static boolean profileShape(JsonObject value) { + return value.has("schema_version") && (value.has("ores") || value.has("fluid_deposits") || value.has("oil")); + } + + private static Set outputs(JsonObject rule, String id, String path, String owner, Predicate exists, + List changes, List problems) { + if (!rule.has("block")) { + String mapped = mapped(id, owner); + if (!mapped.equals(id) && validTarget(mapped, path, exists, problems)) { + rule.addProperty("block", mapped); + changes.add(path + "/block: implicit " + id + " -> " + mapped); + } + } else rewrite(rule, "block", path, owner, exists, changes, problems); + rewrite(rule, "deep_output", path, owner, exists, changes, problems); + Set result = new LinkedHashSet<>(); + String primary = text(rule.get("block")); + if (primary != null) result.add(primary); + else if (!id.isEmpty()) result.add(id); + String deep = text(rule.get("deep_output")); + if (deep != null) result.add(deep); + JsonElement outputs = rule.get("outputs"); + if (outputs != null) { + if (!outputs.isJsonArray()) problems.add(path + "/outputs: expected array"); + else { + int index = 0; + for (JsonElement value : outputs.getAsJsonArray()) { + if (!value.isJsonObject()) problems.add(path + "/outputs/" + index + ": expected output object"); + else { + JsonObject output = value.getAsJsonObject(); + rewrite(output, "block", path + "/outputs/" + index, owner, exists, changes, problems); + String block = text(output.get("block")); + if (block != null) result.add(block); + } + index++; + } + } + } + return result; + } + + private static void rewrite(JsonObject object, String key, String path, String owner, Predicate exists, + List changes, List problems) { + if (!object.has(key)) return; + String before = text(object.get(key)); + if (before == null) { problems.add(path + "/" + key + ": expected output identifier string"); return; } + String after = mapped(before, owner); + if (!after.equals(before) && validTarget(after, path + "/" + key, exists, problems)) { + object.addProperty(key, after); + changes.add(path + "/" + key + ": " + before + " -> " + after); + } + } + + private static String mapped(String value, String owner) { + if ("basepower".equals(owner) && value.startsWith("poweradvantage:")) return "basepower:" + value.substring("poweradvantage:".length()); + if ("baseelectric".equals(owner) && value.startsWith("electricadvantage:")) return "baseelectric:" + value.substring("electricadvantage:".length()); + return value.equals("minecraft:quartz_ore") ? "minecraft:nether_quartz_ore" : value; + } + + private static boolean legacyId(String value) { + return value != null && (value.startsWith("poweradvantage:") || value.startsWith("electricadvantage:") + || value.startsWith("basepower:legacy/") || value.startsWith("baseelectric:legacy/") + || value.equals("minecraft:quartz_ore") || value.equals("orespawn:legacy/orespawn/quartz_ore")); + } + + private static Set domains(JsonObject root, JsonObject rule, String section, Set outputs, + String path, List problems) { + if (!enabled(rule, "enabled", true, path, problems)) return Set.of(); + if (section.equals("fluid_deposits") && !enabled(root, "place_fluid_deposits", true, path, problems)) return Set.of(); + if (section.equals("ores") && (outputs.contains("minecraft:nether_quartz_ore") + || enabled(rule, "native_generation", false, path, problems)) + && !enabled(root, "manage_vanilla_ores", false, path, problems)) return Set.of(); + Set result = new LinkedHashSet<>(); + for (String key : List.of("dimensions", "dimension_selectors")) { + JsonObject table = object(rule, key, path, problems); + if (table == null) continue; + for (Map.Entry entry : table.entrySet()) { + if (!entry.getValue().isJsonObject()) { problems.add(path + "/" + key + ": expected placement object"); continue; } + JsonObject placement = entry.getValue().getAsJsonObject(); + if (enabled(placement, "enabled", true, path, problems) && positive(placement, path, problems)) result.add(entry.getKey()); + } + } + return result; + } + + private static boolean positive(JsonObject rule, String path, List problems) { + if (!rule.has("frequency")) return true; + JsonElement value = rule.get("frequency"); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + problems.add(path + "/frequency: expected number"); return false; + } + return value.getAsBigDecimal().signum() > 0; + } + + private static boolean enabled(JsonObject object, String key, boolean fallback, String path, List problems) { + if (!object.has(key)) return fallback; + JsonElement value = object.get(key); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { + problems.add(path + "/" + key + ": expected boolean"); return false; + } + return value.getAsBoolean(); + } + + private static boolean overlap(Set first, Set second) { + for (String a : first) for (String b : second) { + if (a.equals(b) || a.equals(ORDINARY) && ordinary(b) || b.equals(ORDINARY) && ordinary(a)) return true; + } + return false; + } + + private static boolean ordinary(String dimension) { + return !dimension.equals("minecraft:the_nether") && !dimension.equals("minecraft:the_end"); + } + + private static void seed(JsonObject root, String path, JsonObject defaults, String owner, List rules, + Predicate exists, List changes, List problems) { + Set retained = new LinkedHashSet<>(); + for (Rule rule : rules) if (rule.retained) { + for (String output : rule.outputs) if (output.startsWith(owner + ":")) retained.add(output); + } + if (retained.isEmpty()) return; + if (!root.has("provider_modid") && defaults.has("profile_defaults") + && defaults.get("profile_defaults").isJsonObject() && !defaults.getAsJsonObject("profile_defaults").isEmpty()) { + JsonObject manifests = object(root, "providers", path, problems); + if (manifests == null && !root.has("providers")) { + manifests = new JsonObject(); root.add("providers", manifests); + } + if (manifests != null) { + JsonObject manifest = object(manifests, owner, path + "/providers", problems); + if (manifest == null && !manifests.has(owner)) { + manifest = new JsonObject(); manifests.add(owner, manifest); + } + if (manifest != null && !manifest.has("profile_defaults_applied")) { + manifest.addProperty("profile_defaults_applied", true); + changes.add(path + "/providers/" + owner + "/profile_defaults_applied: retained legacy root choices are authoritative"); + } else if (manifest != null && !enabled(manifest, "profile_defaults_applied", false, path, problems)) { + for (Map.Entry entry : defaults.getAsJsonObject("profile_defaults").entrySet()) { + if (root.has(entry.getKey()) && !root.get(entry.getKey()).equals(entry.getValue())) { + problems.add(path + ": explicit provider default overlay conflicts with saved " + entry.getKey()); + } + } + } + } + } + for (String section : List.of("ores", "fluid_deposits")) { + JsonObject source = object(defaults, section, "defaults", problems); + if (source == null) continue; + JsonObject destination = object(root, section, path, problems); + for (Map.Entry entry : source.entrySet()) { + if (destination != null && destination.has(entry.getKey())) continue; + if (!entry.getKey().startsWith(owner + ":") || !entry.getValue().isJsonObject()) { + problems.add("Unowned/malformed default rule: " + entry.getKey()); continue; + } + JsonObject rule = entry.getValue().getAsJsonObject(); + String block = text(rule.get("block")); + if (block == null || !retained.contains(block)) continue; + if (!validTarget(block, "defaults/" + entry.getKey(), exists, problems)) continue; + if (destination == null) { destination = new JsonObject(); root.add(section, destination); } + JsonObject guard = rule.deepCopy(); + guard.addProperty("enabled", false); + guard.addProperty("source_provider", owner); + destination.add(entry.getKey(), guard); + changes.add(path + "/" + section + "/" + entry.getKey() + ": seeded disabled owned default; legacy placement retained"); + if (owner.equals(text(root.get("provider_modid"))) + && new JsonPrimitive(4).equals(root.get("schema_version")) + && section.equals("ores") && requiresProviderSchemaFive(guard)) { + root.addProperty("schema_version", 5); + changes.add(path + "/schema_version: 4 -> 5; seeded ore fields require provider schema 5"); + } + } + } + } + + private static boolean requiresProviderSchemaFive(JsonObject ore) { + if (ore.has("material")) return true; + for (String section : List.of("dimensions", "dimension_selectors")) { + JsonElement placements = ore.get(section); + if (placements == null || !placements.isJsonObject()) continue; + for (JsonElement placement : placements.getAsJsonObject().asMap().values()) { + if (placement.isJsonObject() && placement.getAsJsonObject().has("placement_channel")) return true; + } + } + return false; + } + + private static boolean validTarget(String value, String path, Predicate exists, List problems) { + boolean valid = value.matches("[a-z0-9_.-]+:[a-z0-9_./-]+") && !value.equals("minecraft:air"); + try { valid = valid && exists.test(value); } + catch (RuntimeException error) { valid = false; } + if (!valid) problems.add(path + ": invalid/unavailable target " + value); + return valid; + } + + private static JsonObject object(JsonObject root, String key, String path, List problems) { + if (!root.has(key)) return null; + if (root.get(key).isJsonObject()) return root.getAsJsonObject(key); + problems.add(path + "/" + key + ": expected object"); + return null; + } + + private static String text(JsonElement value) { + return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : null; + } + + /** + * Migrates one caller-selected file. Atomic moves are mandatory; unsupported filesystems + * fail rather than falling back to non-atomic replacement. Content-addressed backups and + * reports never replace different pre-existing evidence. PREPARED is not a success receipt. + */ + public static synchronized Result migrate(Path file, JsonObject ownedDefaults, Predicate targetExists) throws IOException { + Path path = file.toAbsolutePath().normalize(); + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return new Result(Status.MISSING, null, null, null); + requireRegular(path); + if (Files.size(path) > MAX_BYTES) throw new IOException("Oversized OreSpawn profile: " + path); + byte[] before = Files.readAllBytes(path); + if (before.length > MAX_BYTES) throw new IOException("Oversized OreSpawn profile: " + path); + String beforeHash = hash(before); + Plan plan; + try { plan = plan(parse(before), ownedDefaults, targetExists); } + catch (RuntimeException error) { throw new IOException("Invalid OreSpawn profile: " + path, error); } + if (!plan.isRefused() && !plan.isChanged()) return new Result(Status.UNCHANGED, null, null, plan); + Path backup = path.resolveSibling(path.getFileName() + ".advantage-output-" + beforeHash + ".bak"); + byte[] after = bytes(plan.getJson()); + JsonObject receipt = new JsonObject(); + receipt.addProperty("format", FORMAT); + receipt.addProperty("file", path.toString()); + receipt.addProperty("source_sha256", beforeHash); + receipt.addProperty("result_sha256", plan.isRefused() ? beforeHash : hash(after)); + receipt.addProperty("backup", plan.isRefused() ? null : backup.toString()); + receipt.add("changes", GSON.toJsonTree(plan.getChanges())); + receipt.add("problems", GSON.toJsonTree(plan.getProblems())); + receipt.add("warnings", GSON.toJsonTree(plan.getWarnings())); + receipt.addProperty("status", plan.isRefused() ? "REFUSED" : "PREPARED"); + Path report = path.resolveSibling(path.getFileName() + ".advantage-output-" + beforeHash + + "-" + hash(bytes(receipt)).substring(0, 16) + ".json"); + if (Files.exists(report, LinkOption.NOFOLLOW_LINKS)) { + requireRegular(report); + JsonObject previous = parse(Files.readAllBytes(report)); + JsonObject previousBody = previous.deepCopy(), currentBody = receipt.deepCopy(); + String previousStatus = text(previousBody.remove("status")); + currentBody.remove("status"); + if (previousStatus == null || !List.of("PREPARED", "MIGRATED", "REFUSED").contains(previousStatus) + || !previousBody.equals(currentBody)) { + throw new IOException("Conflicting migration report: " + report); + } + } + if (plan.isRefused()) { + if (!Files.exists(report)) atomicWrite(report, bytes(receipt)); + return new Result(Status.REFUSED, null, report, plan); + } + if (Files.exists(backup, LinkOption.NOFOLLOW_LINKS)) { + requireRegular(backup); + if (!Arrays.equals(Files.readAllBytes(backup), before)) throw new IOException("Conflicting migration backup: " + backup); + } else atomicWrite(backup, before); + atomicWrite(report, bytes(receipt)); + requireRegular(path); + if (!Arrays.equals(before, Files.readAllBytes(path))) throw new IOException("Profile changed during migration: " + path); + atomicWrite(path, after); + receipt.addProperty("status", "MIGRATED"); + atomicWrite(report, bytes(receipt)); + return new Result(Status.MIGRATED, backup, report, plan); + } + + private static void requireRegular(Path path) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || !path.toRealPath().equals(path)) { + throw new IOException("Aliased/non-regular migration file: " + path); + } + for (Path part = path; part != null; part = part.getParent()) { + if (Files.isSymbolicLink(part)) throw new IOException("Aliased migration path: " + path); + } + } + + private static void atomicWrite(Path path, byte[] content) throws IOException { + Path temporary = Files.createTempFile(path.getParent(), ".advantage-output-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(content); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { Files.deleteIfExists(temporary); } + } + + private static byte[] bytes(JsonObject object) { return (GSON.toJson(object) + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8); } + + private static String hash(byte[] bytes) { + try { return HexFormat.of().withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + + private static JsonObject parse(byte[] bytes) throws IOException { + String utf8 = java.nio.charset.StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); + try (JsonReader reader = new JsonReader(new StringReader(utf8))) { + reader.setStrictness(Strictness.STRICT); + JsonElement value = read(reader, 0); + if (!value.isJsonObject() || reader.peek() != JsonToken.END_DOCUMENT) throw new IOException("Expected one profile object"); + return value.getAsJsonObject(); + } + } + + private static JsonElement read(JsonReader reader, int depth) throws IOException { + if (depth > 64) throw new IOException("Profile nesting limit exceeded"); + switch (reader.peek()) { + case BEGIN_OBJECT: + JsonObject object = new JsonObject(); reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (object.has(name)) throw new IOException("Duplicate profile key: " + name); + object.add(name, read(reader, depth + 1)); + } + reader.endObject(); return object; + case BEGIN_ARRAY: + JsonArray array = new JsonArray(); reader.beginArray(); + while (reader.hasNext()) array.add(read(reader, depth + 1)); + reader.endArray(); return array; + case STRING: return new JsonPrimitive(reader.nextString()); + case NUMBER: return new JsonPrimitive(new BigDecimal(reader.nextString())); + case BOOLEAN: return new JsonPrimitive(reader.nextBoolean()); + case NULL: reader.nextNull(); return JsonNull.INSTANCE; + default: throw new IOException("Invalid profile token: " + reader.peek()); + } + } +} diff --git a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java index 464c6f56..f5d271b4 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java +++ b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java @@ -4,7 +4,7 @@ import zone.moddev.mc.basepower.PowerAdvantage; import zone.moddev.mc.basepower.math.Integer2D; import zone.moddev.mc.basepower.registry.ITileEntityGUI; -import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Inventory; @@ -13,8 +13,6 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.resources.Identifier; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraft.client.renderer.RenderPipelines; import org.joml.Matrix3x2fStack; import net.minecraft.network.chat.Component; @@ -115,7 +113,6 @@ public SimpleMachineGUI(String guiImage, Integer2D... inventorySlotCoordinates){ * the GUI * @param z This is the z coordinate (no units) into the depth of the screen */ - @OnlyIn(Dist.CLIENT) public void drawGUIDecorations(Object srcEntity, GUIContainer guiContainer, int x, int y, float z){} /** @@ -147,12 +144,10 @@ public Container createMenu(int windowId, Inventory player, net.minecraft.world. * @return An instance of net.minecraft.client.gui.screen.inventory.ContainerScreen */ @Override - @OnlyIn(Dist.CLIENT) public AbstractContainerScreen getContainerGUI(BlockEntity e, Player player) { return ClientScreens.legacy(this, e, player); } - @OnlyIn(Dist.CLIENT) private static final class ClientScreens { private static AbstractContainerScreen legacy(SimpleMachineGUI gui, BlockEntity tile, Player player) { return gui.new GUIContainer(player.getInventory(), (net.minecraft.world.Container) tile); @@ -188,7 +183,6 @@ public Container(int windowId, Inventory playerItems, net.minecraft.world.Contai bindPlayerInventory(playerItems, PLAYER_SLOTS_TOP); } - @OnlyIn(Dist.CLIENT) public GUIContainer createScreen(Inventory inventory, Component title) { return new GUIContainer(this, inventory, title); } @@ -252,11 +246,10 @@ else if (!this.moveItemStackTo(stackInSlot, 0, hostSize, false)) { * requirements of the interface ITileEntityGUI. */ - @OnlyIn(Dist.CLIENT) public class GUIContainer extends net.minecraft.client.gui.screens.inventory.AbstractContainerScreen{ private final Object entity; private final Component playerInventoryLabel; - private GuiGraphics graphics; + private GuiGraphicsExtractor graphics; public net.minecraft.client.Minecraft getMinecraft() { return minecraft; } public GUIContainer(Inventory playerItems, net.minecraft.world.Container entity) { @@ -264,19 +257,15 @@ public GUIContainer(Inventory playerItems, net.minecraft.world.Container entity) } public GUIContainer(Container container, Inventory playerItems, Component title) { - super(container, playerItems, title); + super(container, playerItems, title, guiWidth, guiHeight); this.playerInventoryLabel = playerItems.getDisplayName(); //System.out.println("Size: "+this.xSize+"x"+this.ySize+" at coordinate ("+this.guiLeft+","+this.guiTop+")"); - this.imageWidth = guiWidth; - this.imageHeight = guiHeight; this.entity = container.entity; } @Override - public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTicks) { - this.renderBackground(graphics, mouseX, mouseY, partialTicks); - super.render(graphics, mouseX, mouseY, partialTicks); - this.renderTooltip(graphics, mouseX, mouseY); + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTicks) { + super.extractRenderState(graphics, mouseX, mouseY, partialTicks); if (font.width(title.getString()) > width - 8 && mouseY >= topPos + 1 && mouseY < topPos + MACHINE_TOP) { graphics.setTooltipForNextFrame(font, title, mouseX, mouseY); @@ -284,10 +273,10 @@ public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTi } @Override - protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { String machineName = fitLabel(title.getString(), width - 8); - graphics.drawString(font, machineName, (imageWidth - font.width(machineName)) / 2, 1, 0xFFFFFFFF); - graphics.drawString(font, fitLabel(playerInventoryLabel.getString(), imageWidth - 16), + graphics.text(font, machineName, (imageWidth - font.width(machineName)) / 2, 1, 0xFFFFFFFF); + graphics.text(font, fitLabel(playerInventoryLabel.getString(), imageWidth - 16), 8, MACHINE_TOP + MACHINE_HEIGHT, 0xFFFFFFFF); } @@ -297,7 +286,8 @@ private String fitLabel(String text, int availableWidth) { } @Override - protected void renderBg(GuiGraphics graphics, float partialTicks, int mouseX, int mouseY) { + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTicks) { + super.extractBackground(graphics, mouseX, mouseY, partialTicks); this.graphics = graphics; int x = (width - imageWidth) / 2; int y = (height - imageHeight) / 2; @@ -322,7 +312,7 @@ public void blit(Identifier texture, int x, int y, int u, int v, int width, int } /** The active render context, available while drawing decorations. */ - public GuiGraphics getGraphics() { return graphics; } + public GuiGraphicsExtractor getGraphics() { return graphics; } /** Native GUI transforms are two-dimensional; rotations use radians. */ public Matrix3x2fStack getMatrices() { return getGraphics().pose(); } diff --git a/src/main/java/zone/moddev/mc/basepower/PowerAdvantage.java b/src/main/java/zone/moddev/mc/basepower/PowerAdvantage.java index 17717ad1..8dd1ce38 100644 --- a/src/main/java/zone/moddev/mc/basepower/PowerAdvantage.java +++ b/src/main/java/zone/moddev/mc/basepower/PowerAdvantage.java @@ -14,13 +14,14 @@ import cyano.poweradvantage.api.migration.LegacyOreSpawnConfigBridge; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.fml.DistExecutor; +import net.minecraftforge.fml.loading.FMLEnvironment; import net.minecraftforge.fml.ModList; import net.minecraftforge.eventbus.api.bus.BusGroup; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.config.ModConfig; import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; +import net.minecraftforge.fml.event.lifecycle.FMLLoadCompleteEvent; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.fml.loading.FMLPaths; import zone.moddev.mc.basepower.config.PowerAdvantageConfig; @@ -42,7 +43,7 @@ public final class PowerAdvantage { public static final String MODID = "basepower"; public static final String NAME = "basePower"; - public static final String VERSION = "2.4.15.121111"; + public static final String VERSION = "2.4.16.2601021"; public static final Logger LOGGER = LogManager.getLogger(MODID); private static PowerAdvantage instance; @@ -77,13 +78,15 @@ public PowerAdvantage(FMLJavaModLoadingContext context) { AdvantageLegacyMigrations.registerPowerAdvantage(); zone.moddev.mc.basepower.migration.VanillaRegistryRemaps.register(); + zone.moddev.mc.basepower.migration.OreSpawnProfileMigration.registerServerHook(); net.minecraftforge.event.level.ChunkEvent.Load.BUS.addListener(ConduitRegistry.getInstance()::onChunkLoad); net.minecraftforge.event.level.ChunkEvent.Unload.BUS.addListener(ConduitRegistry.getInstance()::onChunkUnload); net.minecraftforge.event.entity.player.PlayerEvent.PlayerLoggedInEvent.BUS.addListener(RecipeDiscovery::onPlayerLogin); - net.minecraftforge.event.village.VillagerTradesEvent.BUS.addListener(zone.moddev.mc.basepower.init.Villages::onTrades); net.minecraftforge.event.AddPackFindersEvent.BUS.addListener( zone.moddev.mc.basepower.recipe.MaterialTags::registerPack); net.minecraftforge.registries.RegisterEvent.getBus(modBus).addListener(zone.moddev.mc.basepower.init.Blocks::registerBlocks); + net.minecraftforge.registries.RegisterEvent.getBus(modBus).addListener( + zone.moddev.mc.basepower.migration.LegacyOreSpawnRegistryAliases::register); net.minecraftforge.registries.RegisterEvent.getBus(modBus).addListener(zone.moddev.mc.basepower.init.Blocks::registerBlockItems); net.minecraftforge.registries.RegisterEvent.getBus(modBus).addListener(zone.moddev.mc.basepower.init.Items::register); net.minecraftforge.registries.RegisterEvent.getBus(modBus).addListener(zone.moddev.mc.basepower.init.Fluids::registerFluids); @@ -101,15 +104,15 @@ public PowerAdvantage(FMLJavaModLoadingContext context) { net.minecraftforge.fml.event.config.ModConfigEvent.Loading.getBus(modBus).addListener(PowerAdvantageConfig::onConfigLoading); net.minecraftforge.fml.event.config.ModConfigEvent.Reloading.getBus(modBus).addListener(PowerAdvantageConfig::onConfigLoading); FMLCommonSetupEvent.getBus(modBus).addListener(this::commonSetup); + FMLLoadCompleteEvent.getBus(modBus).addListener(this::finishOreSpawnMigration); - DistExecutor.runWhenOn(Dist.CLIENT, () -> () -> { + if (FMLEnvironment.dist == Dist.CLIENT) { net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent.getBus(modBus).addListener( (net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent event) -> event.enqueueWork(() -> { MachineGUIRegistry.registerScreens(); - zone.moddev.mc.basepower.client.ClientOilRenderer.configureRenderLayers(); })); - }); + } } private static void migrateLegacyOreSpawnConfiguration() { @@ -120,8 +123,9 @@ private static void migrateLegacyOreSpawnConfiguration() { LOGGER.info("Imported {} legacy Power Advantage OreSpawn rule(s) from '{}' into '{}'", result.getRuleCount(), result.getSource(), result.getTarget()); } + zone.moddev.mc.basepower.migration.OreSpawnProfileMigration.registerPowerProvider(FMLPaths.CONFIGDIR.get()); } catch (IOException exception) { - LOGGER.error("Could not migrate legacy Power Advantage OreSpawn configuration", exception); + throw new IllegalStateException("Could not safely migrate legacy Power Advantage OreSpawn configuration", exception); } } @@ -148,7 +152,7 @@ private static void migrateLegacyConfiguration() { private void commonSetup(FMLCommonSetupEvent event) { Fuels.init(); GUI.init(); - if (ModList.get().isLoaded("basemetals")) { + if (ModList.isLoaded("basemetals")) { event.enqueueWork(() -> BaseMetalsLegacyMigration.register()); } // Forge's shared hook list is not safe to mutate during parallel mod construction. @@ -158,6 +162,22 @@ private void commonSetup(FMLCommonSetupEvent event) { LOGGER.info("Power Advantage {} common setup complete", VERSION); } + private void finishOreSpawnMigration(FMLLoadCompleteEvent event) { + event.enqueueWork(() -> { + try { + // Both queued bake orders read the repaired file, before any world is opened. + if (zone.moddev.mc.basepower.migration.OreSpawnProfileMigration + .migrateLateProfiles(FMLPaths.CONFIGDIR.get())) { + zone.moddev.mc.orespawn.worldgen.GeomeConfig.bake(); + zone.moddev.mc.orespawn.worldgen.OreSpawnOreGeneration.refreshWorldConfig(); + zone.moddev.mc.orespawn.worldgen.FluidDepositFeature.refreshWorldConfig(); + } + } catch (IOException exception) { + throw new IllegalStateException("Could not safely finish OreSpawn profile migration", exception); + } + }); + } + /** Legacy API retained for add-ons that pass the mod owner to GUIBlock. */ public static PowerAdvantage getInstance() { return instance; diff --git a/src/main/java/zone/moddev/mc/basepower/client/ClientOilRenderer.java b/src/main/java/zone/moddev/mc/basepower/client/ClientOilRenderer.java index c7d78df3..69efca33 100644 --- a/src/main/java/zone/moddev/mc/basepower/client/ClientOilRenderer.java +++ b/src/main/java/zone/moddev/mc/basepower/client/ClientOilRenderer.java @@ -1,22 +1,53 @@ package zone.moddev.mc.basepower.client; import net.minecraft.client.renderer.chunk.ChunkSectionLayer; -import net.minecraft.client.renderer.ItemBlockRenderTypes; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.block.BlockAndTintGetter; +import net.minecraft.client.renderer.block.FluidModel; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.model.sprite.Material; +import net.minecraft.core.BlockPos; +import net.minecraft.data.AtlasIds; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.material.FluidState; +import net.minecraftforge.client.extensions.common.IClientFluidTypeExtensions; +import zone.moddev.mc.basepower.PowerAdvantage; -/** Configure the translucent render layer for the registered oils. */ -@OnlyIn(Dist.CLIENT) +/** Client-only oil models, resolved against the current animated block atlas. */ public final class ClientOilRenderer { private ClientOilRenderer() {} - public static void configureRenderLayers() { - for (net.minecraft.world.level.material.Fluid fluid : new net.minecraft.world.level.material.Fluid[] { - zone.moddev.mc.basepower.init.Fluids.crude_oil, - zone.moddev.mc.basepower.init.Fluids.flowing_crude_oil, - zone.moddev.mc.basepower.init.Fluids.refined_oil, - zone.moddev.mc.basepower.init.Fluids.flowing_refined_oil}) { - ItemBlockRenderTypes.setRenderLayer(fluid, ChunkSectionLayer.TRANSLUCENT); + public static IClientFluidTypeExtensions extensions(String name) { + return new OilModel(name); + } + + private static final class OilModel implements IClientFluidTypeExtensions { + private final Identifier still; + private final Identifier flowing; + private volatile FluidModel cached; + + private OilModel(String name) { + still = Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, "blocks/" + name + "_still"); + flowing = Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, "blocks/" + name + "_flow"); + } + + @Override public Identifier getStillTexture() { return still; } + @Override public Identifier getFlowingTexture() { return flowing; } + + @Override + public FluidModel getModel(FluidState state, BlockAndTintGetter level, BlockPos pos, FluidModel vanilla) { + var atlas = Minecraft.getInstance().getAtlasManager().getAtlasOrThrow(AtlasIds.BLOCKS); + TextureAtlasSprite stillSprite = atlas.getSprite(still); + TextureAtlasSprite flowingSprite = atlas.getSprite(flowing); + FluidModel model = cached; + // A resource reload replaces sprites, so never retain a model from the old atlas. + if (model == null || model.stillMaterial().sprite() != stillSprite + || model.flowingMaterial().sprite() != flowingSprite) { + model = new FluidModel(ChunkSectionLayer.TRANSLUCENT, + new Material.Baked(stillSprite, true), new Material.Baked(flowingSprite, true), null, null); + cached = model; + } + return model; } } diff --git a/src/main/java/zone/moddev/mc/basepower/client/FluidGaugeRenderer.java b/src/main/java/zone/moddev/mc/basepower/client/FluidGaugeRenderer.java new file mode 100644 index 00000000..d88164c7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/client/FluidGaugeRenderer.java @@ -0,0 +1,51 @@ +package zone.moddev.mc.basepower.client; + +import cyano.poweradvantage.api.fluid.FluidStack; +import cyano.poweradvantage.api.simple.SimpleMachineGUI.GUIContainer; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.BiomeColors; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.data.AtlasIds; +import net.minecraftforge.client.extensions.common.IClientFluidTypeExtensions; +import zone.moddev.mc.basepower.util.ForgeFluidAdapter; + +/** Keeps native client-only types out of common machine GUI definitions. */ +public final class FluidGaugeRenderer { + private FluidGaugeRenderer() {} + + public static void draw(GUIContainer gui, FluidStack stack, int x, int y, int width, int height) { + if (stack == null || stack.getFluid() == null || width <= 0 || height <= 0) return; + var fluid = ForgeFluidAdapter.toForge(stack); + var rendering = IClientFluidTypeExtensions.of(stack.getFluid()); + var still = rendering.getStillTexture(fluid); + if (still == null) return; + var minecraft = gui.getMinecraft(); + var atlas = minecraft.getAtlasManager().getAtlasOrThrow(AtlasIds.BLOCKS); + int color = rendering.getTintColor(fluid); + if (stack.getFluid().isSame(net.minecraft.world.level.material.Fluids.WATER)) { + color = minecraft.level != null && minecraft.player != null + ? BiomeColors.getAverageWaterColor(minecraft.level, minecraft.player.blockPosition()) : 0x3F76E4; + } + extractFluidTiles(gui.getGraphics(), atlas.getSprite(still), x, y, width, height, color); + } + + public static void extractFluidTiles(GuiGraphicsExtractor graphics, TextureAtlasSprite sprite, + int x, int y, int width, int height, int color) { + if (width <= 0 || height <= 0) return; + // Legacy gauges use the RGB tint and texture alpha, not the tint's alpha. + color = 0xFF000000 | (color & 0xFFFFFF); + // Clip full-size native tiles so partial rows do not stretch the texture. + graphics.enableScissor(x, y, x + width, y + height); + try { + for (int dy = 0; dy < height; dy += 16) { + for (int dx = 0; dx < width; dx += 16) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, sprite, + x + dx, y + dy, 16, 16, color); + } + } + } finally { + graphics.disableScissor(); + } + } +} diff --git a/src/main/java/zone/moddev/mc/basepower/compat/BaseMetalsCompat.java b/src/main/java/zone/moddev/mc/basepower/compat/BaseMetalsCompat.java index 9e03b597..17c769d9 100644 --- a/src/main/java/zone/moddev/mc/basepower/compat/BaseMetalsCompat.java +++ b/src/main/java/zone/moddev/mc/basepower/compat/BaseMetalsCompat.java @@ -15,7 +15,7 @@ public final class BaseMetalsCompat { private BaseMetalsCompat() {} - public static boolean isLoaded() { return ModList.get().isLoaded("basemetals"); } + public static boolean isLoaded() { return ModList.isLoaded("basemetals"); } public static void setStrongHammers(boolean enabled) { PowerAdvantage.LOGGER.debug("Ignoring legacy strong-hammers setting on Base Metals 1.13"); diff --git a/src/main/java/zone/moddev/mc/basepower/conduitnetwork/ConduitNetworkManager.java b/src/main/java/zone/moddev/mc/basepower/conduitnetwork/ConduitNetworkManager.java index c77ddb7a..e62b8605 100644 --- a/src/main/java/zone/moddev/mc/basepower/conduitnetwork/ConduitNetworkManager.java +++ b/src/main/java/zone/moddev/mc/basepower/conduitnetwork/ConduitNetworkManager.java @@ -134,8 +134,8 @@ public void invalidateAll() { public void invalidateChunk(net.minecraft.resources.ResourceKey dimension, ChunkPos chunk) { lock.writeLock().lock(); try { - int minX = (chunk.x << 4) - 1; - int minZ = (chunk.z << 4) - 1; + int minX = (chunk.x() << 4) - 1; + int minZ = (chunk.z() << 4) - 1; for (BlockPos4D coord : new ArrayList<>(networkCache.keySet())) { if (coord.dimension.equals(dimension) && coord.pos.getX() >= minX && coord.pos.getX() <= minX + 17 && coord.pos.getZ() >= minZ diff --git a/src/main/java/zone/moddev/mc/basepower/gui/FilteredFluidTankGUI.java b/src/main/java/zone/moddev/mc/basepower/gui/FilteredFluidTankGUI.java index e50225ac..7d745805 100644 --- a/src/main/java/zone/moddev/mc/basepower/gui/FilteredFluidTankGUI.java +++ b/src/main/java/zone/moddev/mc/basepower/gui/FilteredFluidTankGUI.java @@ -1,7 +1,5 @@ package zone.moddev.mc.basepower.gui; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.api.distmarker.OnlyIn; import cyano.poweradvantage.api.fluid.FluidPoweredEntity; import cyano.poweradvantage.api.simple.SimpleMachineGUI; @@ -18,7 +16,6 @@ public FilteredFluidTankGUI(Identifier image) { } @Override - @OnlyIn(Dist.CLIENT) public void drawGUIDecorations(Object srcEntity, GUIContainer guiContainer, int x, int y, float z) { // draw indicator if (srcEntity instanceof FluidPoweredEntity) { @@ -34,7 +31,6 @@ public void drawGUIDecorations(Object srcEntity, GUIContainer guiContainer, int } - @OnlyIn(Dist.CLIENT) public static void drawFluidBar( FluidStack fs, float barHeight, int xPos, int yPos, Identifier displayImage, GUIContainer guiContainer, int x, int y, float zLevel diff --git a/src/main/java/zone/moddev/mc/basepower/gui/FluidTankGUI.java b/src/main/java/zone/moddev/mc/basepower/gui/FluidTankGUI.java index 5dee7214..b97fa9f7 100644 --- a/src/main/java/zone/moddev/mc/basepower/gui/FluidTankGUI.java +++ b/src/main/java/zone/moddev/mc/basepower/gui/FluidTankGUI.java @@ -3,18 +3,7 @@ import cyano.poweradvantage.api.fluid.FluidPoweredEntity; import cyano.poweradvantage.api.simple.SimpleMachineGUI; import zone.moddev.mc.basepower.math.Integer2D; -import net.minecraft.client.gui.render.TextureSetup; -import net.minecraft.client.gui.render.state.BlitRenderState; -import net.minecraft.client.gui.render.state.GuiRenderState; -import net.minecraft.client.gui.navigation.ScreenRectangle; -import net.minecraft.client.renderer.RenderPipelines; -import net.minecraft.client.renderer.texture.TextureAtlasSprite; -import net.minecraft.data.AtlasIds; import net.minecraft.resources.Identifier; -import net.minecraft.client.renderer.BiomeColors; -import org.joml.Matrix3x2f; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.api.distmarker.OnlyIn; import cyano.poweradvantage.api.fluid.FluidStack; @@ -28,7 +17,6 @@ public FluidTankGUI(Identifier image) { } @Override - @OnlyIn(Dist.CLIENT) public void drawGUIDecorations(Object srcEntity, GUIContainer guiContainer, int x, int y, float z) { // draw indicator if (srcEntity instanceof FluidPoweredEntity) { @@ -44,7 +32,6 @@ public void drawGUIDecorations(Object srcEntity, GUIContainer guiContainer, int } - @OnlyIn(Dist.CLIENT) public static void drawFluidBar( FluidStack fs, float barHeight, int xPos, int yPos, Identifier displayImage, GUIContainer guiContainer, int x, int y, float zLevel @@ -58,57 +45,16 @@ public static void drawFluidBar( } - @OnlyIn(Dist.CLIENT) public static void drawFluidFilledRectangle( GUIContainer guiContainer, FluidStack fs, int x, int y, int width, int height, float zLevel ) { - if (fs == null || fs.getFluid() == null || width <= 0 || height <= 0) return; - // The atlas supplies the current animation frame; water's sprite is grayscale. - var fluidStack = zone.moddev.mc.basepower.util.ForgeFluidAdapter.toForge(fs); - var rendering = net.minecraftforge.client.extensions.common.IClientFluidTypeExtensions.of(fs.getFluid()); - var stillTexture = rendering.getStillTexture(fluidStack); - if (stillTexture == null) return; - var atlas = guiContainer.getMinecraft().getAtlasManager().getAtlasOrThrow(AtlasIds.BLOCKS); - TextureAtlasSprite sprite = atlas.getSprite(stillTexture); - int color = rendering.getTintColor(fluidStack); - if (fs.getFluid().isSame(net.minecraft.world.level.material.Fluids.WATER)) { - color = guiContainer.getMinecraft().level != null && guiContainer.getMinecraft().player != null - ? BiomeColors.getAverageWaterColor(guiContainer.getMinecraft().level, guiContainer.getMinecraft().player.blockPosition()) : 0x3F76E4; - } - var graphics = guiContainer.getGraphics(); - var texture = TextureSetup.singleTexture(atlas.getTextureView(), atlas.getSampler()); - extractFluidTiles(graphics.getRenderState(), texture, new Matrix3x2f(graphics.pose()), - graphics.getScissorStack().peek(), x, y, width, height, - sprite.getU0(), sprite.getU1(), sprite.getV0(), sprite.getV1(), color); + zone.moddev.mc.basepower.client.FluidGaugeRenderer.draw(guiContainer, fs, x, y, width, height); } static int gaugeHeight(float level) { return (int) (60 * (Float.isFinite(level) ? Math.max(0F, Math.min(1F, level)) : 0F)); } - @OnlyIn(Dist.CLIENT) - static void extractFluidTiles(GuiRenderState target, TextureSetup texture, Matrix3x2f pose, - ScreenRectangle scissor, int x, int y, int width, int height, - float spriteU0, float spriteU1, float spriteV0, float spriteV1, int color) { - if (width <= 0 || height <= 0) return; - // Legacy gauges use the fluid's RGB tint and texture alpha, not its tint alpha. - color = 0xFF000000 | (color & 0xFFFFFF); - final int tileSize = 16; - for (int dy = 0; dy < height; dy += tileSize) { - int tileHeight = Math.min(tileSize, height - dy); - for (int dx = 0; dx < width; dx += tileSize) { - int tileWidth = Math.min(tileSize, width - dx); - // Sprite coordinates are fractions of the tile, even when drawing only part of it. - float u1 = spriteU0 + (spriteU1 - spriteU0) * tileWidth / tileSize; - float v1 = spriteV0 + (spriteV1 - spriteV0) * tileHeight / tileSize; - target.submitGuiElement(new BlitRenderState( - RenderPipelines.GUI_TEXTURED, texture, new Matrix3x2f(pose), - x + dx, y + dy, x + dx + tileWidth, y + dy + tileHeight, - spriteU0, u1, spriteV0, v1, color, scissor)); - } - } - } - } diff --git a/src/main/java/zone/moddev/mc/basepower/gui/StillGUI.java b/src/main/java/zone/moddev/mc/basepower/gui/StillGUI.java index 572ea016..cab1804c 100644 --- a/src/main/java/zone/moddev/mc/basepower/gui/StillGUI.java +++ b/src/main/java/zone/moddev/mc/basepower/gui/StillGUI.java @@ -1,7 +1,5 @@ package zone.moddev.mc.basepower.gui; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.api.distmarker.OnlyIn; import cyano.poweradvantage.api.simple.SimpleMachineGUI; import zone.moddev.mc.basepower.machines.fluidmachines.StillTileEntity; @@ -18,7 +16,6 @@ public StillGUI(Identifier image) { } @Override - @OnlyIn(Dist.CLIENT) public void drawGUIDecorations(Object srcEntity, GUIContainer guiContainer, int x, int y, float z) { // draw indicator if (srcEntity instanceof StillTileEntity) { diff --git a/src/main/java/zone/moddev/mc/basepower/init/Fluids.java b/src/main/java/zone/moddev/mc/basepower/init/Fluids.java index 4b2d8504..740688f7 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/Fluids.java +++ b/src/main/java/zone/moddev/mc/basepower/init/Fluids.java @@ -18,8 +18,6 @@ import net.minecraft.core.Registry; import net.minecraft.world.level.LevelAccessor; import net.minecraft.world.level.LevelReader; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.client.extensions.common.IClientFluidTypeExtensions; import net.minecraftforge.fluids.FluidType; @@ -89,10 +87,7 @@ private static FluidType oilType(String name, int density, int viscosity) { return new FluidType(FluidType.Properties.create().descriptionId("fluid.basepower." + name) .density(density).viscosity(viscosity).temperature(300)) { @Override public void initializeClient(Consumer consumer) { - consumer.accept(new IClientFluidTypeExtensions() { - @Override public Identifier getStillTexture() { return id("blocks/" + name + "_still"); } - @Override public Identifier getFlowingTexture() { return id("blocks/" + name + "_flow"); } - }); + consumer.accept(zone.moddev.mc.basepower.client.ClientOilRenderer.extensions(name)); } }; } diff --git a/src/main/java/zone/moddev/mc/basepower/init/Villages.java b/src/main/java/zone/moddev/mc/basepower/init/Villages.java deleted file mode 100644 index f6040b3a..00000000 --- a/src/main/java/zone/moddev/mc/basepower/init/Villages.java +++ /dev/null @@ -1,24 +0,0 @@ -package zone.moddev.mc.basepower.init; - -import net.minecraft.world.entity.npc.villager.VillagerProfession; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.trading.MerchantOffer; -import net.minecraft.world.item.trading.ItemCost; -import net.minecraftforge.event.village.VillagerTradesEvent; - -/** The old farmer and toolsmith careers now have their own vanilla professions. */ -public final class Villages { - private Villages() {} - - public static void onTrades(VillagerTradesEvent event) { - if (event.getType() == VillagerProfession.TOOLSMITH) { - event.getTrades().get(1).add((level, villager, random) -> new MerchantOffer( - new ItemCost(net.minecraft.world.item.Items.EMERALD), - new ItemStack(Items.sprocket, 1 + random.nextInt(4)), 7, 1, 0.0F)); - } else if (event.getType() == VillagerProfession.FARMER) { - event.getTrades().get(1).add((level, villager, random) -> new MerchantOffer( - new ItemCost(net.minecraft.world.item.Items.EMERALD), - new ItemStack(Items.bioplastic_ingot, 4 + random.nextInt(5)), 7, 1, 0.0F)); - } - } -} diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDischargeTileEntity.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDischargeTileEntity.java index 0bc4990f..532e4033 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDischargeTileEntity.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDischargeTileEntity.java @@ -66,7 +66,7 @@ private static BlockPos findPlacement(Level world, BlockPos initial, Fluid fluid } } if (candidates.isEmpty()) return null; - position = candidates.get(candidates.size() == 1 ? 0 : world.random.nextInt(candidates.size())); + position = candidates.get(candidates.size() == 1 ? 0 : world.getRandom().nextInt(candidates.size())); } return null; } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDrainTileEntity.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDrainTileEntity.java index 4b6a9554..3a42918d 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDrainTileEntity.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDrainTileEntity.java @@ -104,7 +104,7 @@ public static BlockPos scanFluidSpaceForSourceBlock(Level world, BlockPos initia || candidateState.getType().isSame(state.getType()))) candidates.add(candidate); } if (candidates.isEmpty()) return null; - position = candidates.get(candidates.size() == 1 ? 0 : world.random.nextInt(candidates.size())); + position = candidates.get(candidates.size() == 1 ? 0 : world.getRandom().nextInt(candidates.size())); } return null; } diff --git a/src/main/java/zone/moddev/mc/basepower/migration/BaseMetalsLegacyMigration.java b/src/main/java/zone/moddev/mc/basepower/migration/BaseMetalsLegacyMigration.java index 95715db1..df31c951 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/BaseMetalsLegacyMigration.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/BaseMetalsLegacyMigration.java @@ -16,6 +16,11 @@ public static void register() { new BaseMetalsLegacyMigration()); } + @Override + public void prepareWorld(CompoundTag levelSnapshot) { + LegacyWorldDataHook.prepareLegacyEnchantmentContext(levelSnapshot); + } + @Override public boolean prepareData(CompoundTag payload, int dataVersion, Payload kind) { return LegacyWorldDataHook.prepareExternalPayload(payload, kind == Payload.PLAYER); diff --git a/src/main/java/zone/moddev/mc/basepower/migration/LegacyOreSpawnRegistryAliases.java b/src/main/java/zone/moddev/mc/basepower/migration/LegacyOreSpawnRegistryAliases.java new file mode 100644 index 00000000..10a3e2a7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/migration/LegacyOreSpawnRegistryAliases.java @@ -0,0 +1,49 @@ +package zone.moddev.mc.basepower.migration; + +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraftforge.registries.ForgeRegistry; +import net.minecraftforge.registries.RegisterEvent; + +/** Keeps OreSpawn's initial legacy import valid until its profile is canonicalised. */ +public final class LegacyOreSpawnRegistryAliases { + private static final Identifier OLD_QUARTZ = Identifier.parse("minecraft:quartz_ore"); + private static final Identifier QUARTZ = Identifier.parse("minecraft:nether_quartz_ore"); + + private LegacyOreSpawnRegistryAliases() {} + + public static void register(RegisterEvent event) { + if (!event.getRegistryKey().equals(Registries.BLOCK)) return; + if (!(event.getForgeRegistry() instanceof ForgeRegistry registry)) { + throw new IllegalStateException("Legacy ore aliases require Forge's block registry"); + } + install(registry, OLD_QUARTZ, QUARTZ); + } + + static void install(ForgeRegistry registry, Identifier oldId, Identifier currentId) { + if (!registry.getKeys().contains(currentId)) { + throw new IllegalStateException("Missing canonical legacy ore target: " + currentId); + } + T target = registry.getRaw(currentId); + if (target == null || !currentId.equals(registry.getKey(target))) { + throw new IllegalStateException("Missing canonical legacy ore target: " + currentId); + } + var aliases = registry.makeSnapshot().aliases; + var seen = new java.util.HashSet(); + Identifier resolved = oldId; + while (!registry.getKeys().contains(resolved) && aliases.containsKey(resolved)) { + if (!seen.add(resolved)) throw new IllegalStateException("Conflicting legacy ore alias cycle: " + oldId); + resolved = aliases.get(resolved); + } + if (aliases.containsKey(oldId) && !registry.getKeys().contains(resolved)) { + throw new IllegalStateException("Conflicting dangling legacy ore alias: " + oldId); + } + T existing = registry.getRaw(oldId); + if (existing != null) { + if (existing != target) throw new IllegalStateException("Conflicting legacy ore alias: " + oldId); + return; + } + // Forge aliases change lookup only, without adding a block, item or numeric ID. + registry.addAlias(oldId, currentId); + } +} diff --git a/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldDataHook.java b/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldDataHook.java index 62f9e239..4d132400 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldDataHook.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldDataHook.java @@ -36,7 +36,6 @@ import net.minecraft.nbt.NbtUtils; import net.minecraft.resources.Identifier; import net.minecraft.util.datafix.fixes.BlockStateData; -import net.minecraftforge.fml.util.ObfuscationReflectionHelper; import net.minecraftforge.registries.ForgeRegistries; import zone.moddev.mc.basepower.PowerAdvantage; @@ -65,9 +64,10 @@ public static synchronized void prepareLegacyWorld(File levelDat) { LEGACY_ENCHANTMENTS.clear(); LEGACY_CHUNKS.clear(); LEGACY_BLOCK_COUNTS.clear(); - prepareForgeRegistryMetadata(levelDat); - File source = legacyRegistrySource(levelDat); try { + OreSpawnProfileMigration.migrateWorld(levelDat.toPath().toAbsolutePath().getParent()); + prepareForgeRegistryMetadata(levelDat); + File source = legacyRegistrySource(levelDat); if (source != null) { try (FileInputStream input = new FileInputStream(source)) { CompoundTag registryArchive = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); @@ -394,6 +394,12 @@ private static int installLegacyBlockStates(CompoundTag snapshot) { return mapped; } + static synchronized void prepareLegacyEnchantmentContext(CompoundTag levelSnapshot) { + // Base Metals can flatten entity inventories before our selected-world hook. + // Resolve their numeric enchantments from this world's index, never a previous load. + installLegacyEnchantments(levelSnapshot.getCompoundOrEmpty("FML").getCompoundOrEmpty("Registries")); + } + private static void installLegacyEnchantments(CompoundTag registries) { LEGACY_ENCHANTMENTS.clear(); ListTag entries = registries.getCompoundOrEmpty("minecraft:enchantments").getListOrEmpty("ids"); @@ -450,14 +456,17 @@ private static boolean migrateEnchantmentList(CompoundTag compound, String key, private static void migrateLoosePlayerData(File levelDat, CompoundTag root) throws IOException { boolean changed = migrateLegacyNbt(root) > 0; if (changed && levelDat.isFile()) writeWithBackup(levelDat, root); - File playerData = new File(levelDat.getParentFile(), "playerdata"); - File[] files = playerData.listFiles((directory, name) -> name.endsWith(".dat")); - if (files == null) return; - Arrays.sort(files); - for (File file : files) { - try (FileInputStream input = new FileInputStream(file)) { - CompoundTag player = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); - if (migrateLegacyNbt(player) > 0) writeWithBackup(file, player); + // Native file fixing moves playerdata to players/data; reopen must handle the new location too. + for (String directory : List.of("playerdata", "players/data")) { + File[] files = new File(levelDat.getParentFile(), directory) + .listFiles((parent, name) -> name.endsWith(".dat")); + if (files == null) continue; + Arrays.sort(files); + for (File file : files) { + try (FileInputStream input = new FileInputStream(file)) { + CompoundTag player = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); + if (migrateLegacyNbt(player) > 0) writeWithBackup(file, player); + } } } } @@ -602,15 +611,32 @@ public static Map legacyPreparedBlockCounts() { static int indexLegacyChunks(File worldDirectory) { LEGACY_CHUNKS.clear(); - return indexLegacyChunks(Identifier.parse("overworld"), new File(worldDirectory, "region")) + int indexed = indexLegacyChunks(Identifier.parse("overworld"), new File(worldDirectory, "region")) + indexLegacyChunks(Identifier.parse("the_nether"), new File(worldDirectory, "DIM-1/region")) + indexLegacyChunks(Identifier.parse("the_end"), new File(worldDirectory, "DIM1/region")); + Path dimensions = worldDirectory.toPath().resolve("dimensions"); + if (Files.isDirectory(dimensions)) { + try (var directories = Files.walk(dimensions)) { + for (Path directory : directories.filter(Files::isDirectory) + .filter(path -> path.getFileName().toString().equals("region")).toList()) { + Path relative = dimensions.relativize(directory.getParent()); + if (relative.getNameCount() < 2) continue; + Identifier dimension = Identifier.tryParse(relative.getName(0) + ":" + + relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/')); + if (dimension != null) indexed += indexLegacyChunks(dimension, directory.toFile()); + } + } catch (IOException exception) { + throw new IllegalStateException("Could not inspect saved dimension paths in " + dimensions, exception); + } + } + return indexed; } private static int indexLegacyChunks(Identifier dimension, File regionDirectory) { File[] regions = regionDirectory.listFiles((directory, name) -> name.matches("r\\.-?\\d+\\.-?\\d+\\.mc[ar]")); if (regions == null) return 0; - Set chunks = ConcurrentHashMap.newKeySet(); + Set chunks = LEGACY_CHUNKS.computeIfAbsent(dimension, ignored -> ConcurrentHashMap.newKeySet()); + int previous = chunks.size(); byte[] locations = new byte[4096]; for (File region : regions) { String[] parts = region.getName().split("\\."); @@ -636,7 +662,7 @@ private static int indexLegacyChunks(Identifier dimension, File regionDirectory) } } LEGACY_CHUNKS.put(dimension, chunks); - return chunks.size(); + return chunks.size() - previous; } @SuppressWarnings("unchecked") diff --git a/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadata.java b/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadata.java index f89744a7..fee0eed2 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadata.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadata.java @@ -23,17 +23,17 @@ private LegacyWorldMetadata() {} public static CompoundTag read(File levelDat) throws IOException { LegacyWorldDataHook.prepareForgeRegistryMetadata(levelDat); CompoundTag root = NbtIo.readCompressed(levelDat.toPath(), NbtAccounter.unlimitedHeap()); - remap(root, ModList.get()::isLoaded); + remap(root, ModList::isLoaded); return root; } public static Tag remapLightweight(Tag root) { - if (root instanceof CompoundTag compound) remap(compound, ModList.get()::isLoaded); + if (root instanceof CompoundTag compound) remap(compound, ModList::isLoaded); return root; } public static CompoundTag remapRaw(CompoundTag root) { - remap(root, ModList.get()::isLoaded); + remap(root, ModList::isLoaded); return root; } diff --git a/src/main/java/zone/moddev/mc/basepower/migration/OreSpawnProfileMigration.java b/src/main/java/zone/moddev/mc/basepower/migration/OreSpawnProfileMigration.java new file mode 100644 index 00000000..f56563d7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/migration/OreSpawnProfileMigration.java @@ -0,0 +1,98 @@ +package zone.moddev.mc.basepower.migration; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import cyano.poweradvantage.api.migration.LegacyMigrationApi; +import cyano.poweradvantage.api.migration.LegacyOreSpawnProfileMigration; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraftforge.event.server.ServerAboutToStartEvent; +import net.minecraftforge.eventbus.api.listener.Priority; +import zone.moddev.mc.basepower.PowerAdvantage; + +/** Runs output migrations before OreSpawn reads global or selected-world profiles. */ +public final class OreSpawnProfileMigration { + private static final Map DEFAULTS = new LinkedHashMap<>(); + + private OreSpawnProfileMigration() {} + + public static void registerServerHook() { + ServerAboutToStartEvent.BUS.addListener(Priority.HIGHEST, OreSpawnProfileMigration::prepareServerProfile); + } + + private static void prepareServerProfile(ServerAboutToStartEvent event) { + try { + // New worlds can have a pack-supplied profile without any level.dat to datafix. + migrateWorld(event.getServer().getWorldPath(LevelResource.ROOT).normalize()); + } catch (IOException exception) { + throw new IllegalStateException("Could not safely prepare the selected OreSpawn world profile", exception); + } + } + + public static void registerPowerProvider(Path configDirectory) throws IOException { + String resource = "/data/basepower/orespawn/provider.json"; + try (InputStream input = OreSpawnProfileMigration.class.getResourceAsStream(resource)) { + if (input == null) throw new IOException("Missing owned OreSpawn defaults: " + resource); + try (InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + registerProvider(configDirectory, JsonParser.parseReader(reader).getAsJsonObject()); + } + } + } + + /** Called during mod construction, using pure JSON rather than unbound block holders. */ + public static synchronized void registerProvider(Path configDirectory, JsonObject defaults) throws IOException { + String owner = defaults.get("provider_modid").getAsString(); + if (!owner.equals("basepower") && !owner.equals("baseelectric")) { + throw new IllegalArgumentException("Not an Advantage generation provider: " + owner); + } + JsonObject previous = DEFAULTS.putIfAbsent(owner, defaults.deepCopy()); + if (previous != null && !previous.equals(defaults)) { + throw new IllegalStateException("Conflicting OreSpawn migration defaults for " + owner); + } + migrate(configDirectory.resolve("orespawn-worldgen.json"), defaults); + migrate(configDirectory.resolve(owner + "-orespawn.json"), defaults); + } + + /** The caller already owns the selected world's storage lock; no preview path calls this. */ + public static synchronized void migrateWorld(Path worldDirectory) throws IOException { + Path profile = worldDirectory.resolve("serverconfig").resolve("orespawn-worldgen.json"); + for (JsonObject defaults : DEFAULTS.values()) migrate(profile, defaults); + } + + /** OreSpawn can import old vanilla rules during common setup, after our constructors. */ + public static synchronized boolean migrateLateProfiles(Path configDirectory) throws IOException { + boolean changed = false; + for (Map.Entry entry : DEFAULTS.entrySet()) { + changed |= migrate(configDirectory.resolve("orespawn-worldgen.json"), entry.getValue()); + changed |= migrate(configDirectory.resolve(entry.getKey() + "-orespawn.json"), entry.getValue()); + } + return changed; + } + + private static boolean migrate(Path path, JsonObject defaults) throws IOException { + LegacyOreSpawnProfileMigration.Result result = LegacyOreSpawnProfileMigration.migrate( + path, defaults, OreSpawnProfileMigration::declaredTarget); + if (result.status() == LegacyOreSpawnProfileMigration.Status.REFUSED) { + throw new IOException("OreSpawn profile needs review before terrain generation: " + path + + "; " + result.plan().getProblems() + "; report: " + result.reportFile()); + } + if (result.wasMigrated()) { + PowerAdvantage.LOGGER.info("Preserved OreSpawn placement settings while migrating {} profile entries in '{}'; report: {}", + result.plan().getChanges().size(), path, result.reportFile()); + } + return result.wasMigrated(); + } + + static boolean declaredTarget(String name) { + if (name.equals("minecraft:nether_quartz_ore")) return true; + Identifier id = Identifier.tryParse(name); + return id != null && LegacyMigrationApi.blockStateCodecs().containsKey(id); + } +} diff --git a/src/main/java/zone/moddev/mc/basepower/mixin/LevelStorageSourceMixin.java b/src/main/java/zone/moddev/mc/basepower/mixin/LevelStorageSourceMixin.java index 1ee41ac0..bfb95ca4 100644 --- a/src/main/java/zone/moddev/mc/basepower/mixin/LevelStorageSourceMixin.java +++ b/src/main/java/zone/moddev/mc/basepower/mixin/LevelStorageSourceMixin.java @@ -1,32 +1,18 @@ package zone.moddev.mc.basepower.mixin; import java.nio.file.Path; -import com.mojang.datafixers.DataFixer; -import com.mojang.serialization.Dynamic; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.Tag; -import net.minecraft.util.datafix.DataFixTypes; import net.minecraft.world.level.storage.LevelStorageSource; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.Redirect; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -import zone.moddev.mc.basepower.migration.LegacyPlayerDataFixes; -import zone.moddev.mc.basepower.migration.LegacyWorldDataHook; import zone.moddev.mc.basepower.migration.LegacyWorldMetadata; -/** Captures original registry identities before native level and player conversion. */ +/** Applies saved registry aliases to raw level data and read-only world summaries. */ @Mixin(LevelStorageSource.class) abstract class LevelStorageSourceMixin { - @Inject(method = "readLevelDataTagFixed", at = @At("HEAD")) - private static void basepower$prepareWorld(Path file, DataFixer fixer, - CallbackInfoReturnable> callback) { - if (file.getFileName().toString().equals("level.dat")) { - LegacyWorldDataHook.prepareLegacyWorld(file.toFile()); - } - } - @Inject(method = "readLevelDataTagRaw", at = @At("RETURN"), cancellable = true) private static void basepower$remapRaw(Path file, CallbackInfoReturnable callback) { callback.setReturnValue(LegacyWorldMetadata.remapRaw(callback.getReturnValue())); @@ -37,12 +23,4 @@ abstract class LevelStorageSourceMixin { callback.setReturnValue(LegacyWorldMetadata.remapLightweight(callback.getReturnValue())); } - @Redirect(method = "*", at = @At(value = "INVOKE", target = - "Lnet/minecraft/util/datafix/DataFixTypes;updateToCurrentVersion(Lcom/mojang/datafixers/DataFixer;Lcom/mojang/serialization/Dynamic;I)Lcom/mojang/serialization/Dynamic;")) - private static Dynamic basepower$preserveEmbeddedPlayer(DataFixTypes type, DataFixer fixer, - Dynamic source, int version) { - return type == DataFixTypes.PLAYER - ? LegacyPlayerDataFixes.updateNativeEmbeddedPlayer(type, fixer, source, version) - : type.updateToCurrentVersion(fixer, source, version); - } } diff --git a/src/main/java/zone/moddev/mc/basepower/mixin/SelectedWorldStorageMixin.java b/src/main/java/zone/moddev/mc/basepower/mixin/SelectedWorldStorageMixin.java new file mode 100644 index 00000000..8076a2ed --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/mixin/SelectedWorldStorageMixin.java @@ -0,0 +1,27 @@ +package zone.moddev.mc.basepower.mixin; + +import java.nio.file.Path; +import com.mojang.serialization.Dynamic; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraft.world.level.storage.LevelStorageSource; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import zone.moddev.mc.basepower.migration.LegacyWorldDataHook; + +/** Captures the locked, selected world before native file relocation and data fixing. */ +@Mixin(LevelStorageSource.LevelStorageAccess.class) +abstract class SelectedWorldStorageMixin { + @Shadow public abstract Path getLevelPath(LevelResource resource); + + @Inject(method = "getUnfixedDataTag", at = @At(value = "INVOKE", target = + "Lnet/minecraft/world/level/storage/LevelStorageSource;readLevelDataTagRaw(Ljava/nio/file/Path;)Lnet/minecraft/nbt/CompoundTag;")) + private void basepower$prepareWorld(boolean useFallback, CallbackInfoReturnable> callback) { + // The native method has checked its session lock. Never mutate previews or fallback files. + if (!useFallback) { + LegacyWorldDataHook.prepareLegacyWorld(getLevelPath(LevelResource.LEVEL_DATA_FILE).toFile()); + } + } +} diff --git a/src/main/java/zone/moddev/mc/basepower/mixin/SimpleRegionStorageMixin.java b/src/main/java/zone/moddev/mc/basepower/mixin/SimpleRegionStorageMixin.java index 38f390cc..863d3e41 100644 --- a/src/main/java/zone/moddev/mc/basepower/mixin/SimpleRegionStorageMixin.java +++ b/src/main/java/zone/moddev/mc/basepower/mixin/SimpleRegionStorageMixin.java @@ -20,24 +20,24 @@ abstract class SimpleRegionStorageMixin { callback.setReturnValue(LegacyWorldDataHook.copyChunkReadForMigration(callback.getReturnValue())); } - @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;)Lnet/minecraft/nbt/CompoundTag;", + @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;I)Lnet/minecraft/nbt/CompoundTag;", at = @At("HEAD")) - private void basepower$prepareChunk(CompoundTag chunk, int fallback, CompoundTag context, + private void basepower$prepareChunk(CompoundTag chunk, int fallback, CompoundTag context, int targetVersion, CallbackInfoReturnable callback) { LegacyWorldDataHook.prepareLegacyChunk(chunk); } - @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;)Lnet/minecraft/nbt/CompoundTag;", + @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;I)Lnet/minecraft/nbt/CompoundTag;", at = @At(value = "INVOKE", target = "Lnet/minecraft/nbt/NbtUtils;getDataVersion(Lnet/minecraft/nbt/CompoundTag;I)I")) - private void basepower$prepareInventories(CompoundTag chunk, int fallback, CompoundTag context, + private void basepower$prepareInventories(CompoundTag chunk, int fallback, CompoundTag context, int targetVersion, CallbackInfoReturnable callback) { // Both mods' HEAD hooks have run before machine/entity payloads are staged. LegacyWorldDataHook.prepareLegacyEntities(chunk); } - @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;)Lnet/minecraft/nbt/CompoundTag;", + @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;I)Lnet/minecraft/nbt/CompoundTag;", at = @At("RETURN")) - private void basepower$finishChunk(CompoundTag chunk, int fallback, CompoundTag context, + private void basepower$finishChunk(CompoundTag chunk, int fallback, CompoundTag context, int targetVersion, CallbackInfoReturnable callback) { // Mutate the native result without cancelling other mods' return hooks. LegacyWorldDataHook.finalizeLegacyChunk(callback.getReturnValue()); diff --git a/src/main/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPack.java b/src/main/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPack.java index e2d84d0a..f2dd97bc 100644 --- a/src/main/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPack.java +++ b/src/main/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPack.java @@ -34,7 +34,7 @@ final class MaterialPolicyPack implements PackResources { MaterialPolicyPack() { // Pack instances are created for each reload, after common config loading. this(PowerAdvantage.plasticIsAlsoRubber, - PowerAdvantage.recipeMode == RecipeMode.NORMAL && ModList.get().isLoaded("baseelectric")); + PowerAdvantage.recipeMode == RecipeMode.NORMAL && ModList.isLoaded("baseelectric")); } MaterialPolicyPack(boolean plasticIsRubber, boolean normalSolder) { diff --git a/src/main/java/zone/moddev/mc/basepower/recipe/PartsLoot.java b/src/main/java/zone/moddev/mc/basepower/recipe/PartsLoot.java index 5b2211a8..0ff51427 100644 --- a/src/main/java/zone/moddev/mc/basepower/recipe/PartsLoot.java +++ b/src/main/java/zone/moddev/mc/basepower/recipe/PartsLoot.java @@ -49,7 +49,7 @@ protected ObjectArrayList doApply(net.minecraft.world.level.storage.l var resourceManager = context.getLevel().getServer().getResourceManager(); String source = resourceManager.getResource(table.withPath(path -> "loot_table/" + path + ".json")) .map(Resource::sourcePackId).orElse(""); - for (Identifier extra : partTables(table, source, ModList.get()::isLoaded)) { + for (Identifier extra : partTables(table, source, ModList::isLoaded)) { var key = ResourceKey.create(Registries.LOOT_TABLE, extra); context.getResolver().lookupOrThrow(Registries.LOOT_TABLE).get(key) .ifPresent(holder -> holder.value().getRandomItemsRaw(context, loot::add)); @@ -70,7 +70,7 @@ private static boolean isChest(Identifier table) { } private static boolean hasAdditionalLootTables() { - return ModList.get().getMods().stream().anyMatch(mod -> mod.getModId().replace("_", "").replace("-", "") + return ModList.getMods().stream().anyMatch(mod -> mod.getModId().replace("_", "").replace("-", "") .equals("additionalloottables")); } } diff --git a/src/main/java/zone/moddev/mc/basepower/registry/FuelRegistry.java b/src/main/java/zone/moddev/mc/basepower/registry/FuelRegistry.java index 607050f4..d1aef72c 100644 --- a/src/main/java/zone/moddev/mc/basepower/registry/FuelRegistry.java +++ b/src/main/java/zone/moddev/mc/basepower/registry/FuelRegistry.java @@ -161,9 +161,10 @@ public static ItemStack decrementFuelItem(ItemStack fuelItem){ if(fuelItem == null) return null; List> list = FuelRegistry.getInstance().getPostBurnAlgorithmsForItem(fuelItem.getItem()); if(list.isEmpty()){ + var remainder = fuelItem.getCount() == 1 ? fuelItem.getCraftingRemainder() : null; fuelItem.shrink(1); if(fuelItem.isEmpty()){ - fuelItem = fuelItem.getCraftingRemainder(); + fuelItem = remainder == null ? ItemStack.EMPTY : remainder.create(); } } else { for (Function algorithm : list) { diff --git a/src/main/java/zone/moddev/mc/basepower/registry/ITileEntityGUI.java b/src/main/java/zone/moddev/mc/basepower/registry/ITileEntityGUI.java index ab581433..47ef913f 100644 --- a/src/main/java/zone/moddev/mc/basepower/registry/ITileEntityGUI.java +++ b/src/main/java/zone/moddev/mc/basepower/registry/ITileEntityGUI.java @@ -2,8 +2,6 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.api.distmarker.OnlyIn; /** * This interface is used to combine the two different classes of a block GUI @@ -30,6 +28,5 @@ public interface ITileEntityGUI { * @param player The player that is going to use the GUI * @return An instance of net.minecraft.client.gui.screen.inventory.ContainerScreen */ - @OnlyIn(Dist.CLIENT) public abstract net.minecraft.client.gui.screens.inventory.AbstractContainerScreen getContainerGUI(BlockEntity e, Player player); -} \ No newline at end of file +} diff --git a/src/main/java/zone/moddev/mc/basepower/registry/MachineGUIRegistry.java b/src/main/java/zone/moddev/mc/basepower/registry/MachineGUIRegistry.java index 126906a6..27184038 100644 --- a/src/main/java/zone/moddev/mc/basepower/registry/MachineGUIRegistry.java +++ b/src/main/java/zone/moddev/mc/basepower/registry/MachineGUIRegistry.java @@ -16,8 +16,6 @@ import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; import net.minecraft.world.level.Level; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.common.extensions.IForgeMenuType; import net.minecraft.resources.Identifier; import net.minecraftforge.registries.ForgeRegistries; @@ -74,7 +72,6 @@ private static SimpleMachineGUI definition(int id) { return (SimpleMachineGUI) gui; } - @OnlyIn(Dist.CLIENT) public static void registerScreens() { MenuScreens.register(TYPE, (container, inventory, title) -> container.createScreen(inventory, title)); @@ -108,7 +105,6 @@ public Object getServerGuiElement(int id, Player player, Level world, int x, int return gui == null || tile == null ? null : gui.getContainer(tile, player); } - @OnlyIn(Dist.CLIENT) public Object getClientGuiElement(int id, Player player, Level world, int x, int y, int z) { ITileEntityGUI gui = DEFINITIONS.get(id); BlockEntity tile = world.getBlockEntity(new BlockPos(x, y, z)); diff --git a/src/main/java/zone/moddev/mc/basepower/util/InventoryLocks.java b/src/main/java/zone/moddev/mc/basepower/util/InventoryLocks.java index 081a5be8..38876b25 100644 --- a/src/main/java/zone/moddev/mc/basepower/util/InventoryLocks.java +++ b/src/main/java/zone/moddev/mc/basepower/util/InventoryLocks.java @@ -1,20 +1,28 @@ package zone.moddev.mc.basepower.util; import java.lang.reflect.Field; -import net.minecraft.world.item.ItemStack; +import net.minecraft.world.CompoundContainer; +import net.minecraft.world.Container; import net.minecraft.world.level.block.entity.BaseContainerBlockEntity; -import net.minecraft.world.LockCode; -import net.minecraftforge.fml.util.ObfuscationReflectionHelper; /** Keeps automation from bypassing vanilla container locks. */ public final class InventoryLocks { - // Ordinary inventories need no vanilla field access or Forge name remapper. + // CompoundContainer exposes no child getters; single containers use the native API. private static final class Fields { - static final Field CODE = ObfuscationReflectionHelper.findField(BaseContainerBlockEntity.class, "f_58621_"); - static final Field FIRST = ObfuscationReflectionHelper.findField( - net.minecraft.world.CompoundContainer.class, "f_18910_"); - static final Field SECOND = ObfuscationReflectionHelper.findField( - net.minecraft.world.CompoundContainer.class, "f_18911_"); + static final Field FIRST = child("container1"); + static final Field SECOND = child("container2"); + + private static Field child(String name) { + try { + Field field = CompoundContainer.class.getDeclaredField(name); + if (field.getType() != Container.class || !field.trySetAccessible()) { + throw new IllegalStateException("Cannot inspect vanilla compound inventory: " + name); + } + return field; + } catch (NoSuchFieldException failure) { + throw new ExceptionInInitializerError(failure); + } + } } private InventoryLocks() {} @@ -22,10 +30,9 @@ private InventoryLocks() {} public static boolean isLocked(Object inventory) { if (inventory instanceof InventoryWrapper) return ((InventoryWrapper) inventory).isLocked(); try { - if (inventory instanceof net.minecraft.world.CompoundContainer) + if (inventory instanceof CompoundContainer) return isLocked(Fields.FIRST.get(inventory)) || isLocked(Fields.SECOND.get(inventory)); - return inventory instanceof BaseContainerBlockEntity - && !((LockCode) Fields.CODE.get(inventory)) .unlocksWith(ItemStack.EMPTY); + return inventory instanceof BaseContainerBlockEntity container && container.isLocked(); } catch (IllegalAccessException failure) { throw new IllegalStateException("Cannot inspect vanilla inventory lock", failure); } diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index f16efe70..1e49bd18 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -15,14 +15,14 @@ description='''API and shared machines for pressure, electricity, quantum power, [[dependencies.basepower]] modId="basemetals" mandatory=true -versionRange="[3.1.2.121111,4.0.0)" +versionRange="[3.1.2.2601021,4.0.0)" ordering="AFTER" side="BOTH" [[dependencies.basepower]] modId="orespawn" mandatory=true -versionRange="[4.1.0.121111,5.0.0)" +versionRange="[4.1.1.2601021,5.0.0)" ordering="AFTER" side="BOTH" diff --git a/src/main/resources/assets/minecraft/atlases/blocks.json b/src/main/resources/assets/minecraft/atlases/blocks.json index 2f13b278..ebe2a2a7 100644 --- a/src/main/resources/assets/minecraft/atlases/blocks.json +++ b/src/main/resources/assets/minecraft/atlases/blocks.json @@ -1,7 +1,140 @@ { "sources": [ - { "type": "directory", "source": "blocks", "prefix": "blocks/" }, - { "type": "directory", "source": "items", "prefix": "items/" }, - { "type": "directory", "source": "materials", "prefix": "materials/" } + { + "type": "minecraft:single", + "resource": "basepower:blocks/crude_oil_flow" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/crude_oil_still" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/filter_bottom" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/filter_side_block" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/filter_side_food" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/filter_side_fuel" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/filter_side_inventory" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/filter_side_ore" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/filter_side_overflow" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/filter_side_plantable" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/filter_side_smeltable" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/fluid_discharge_face" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/fluid_discharge_side" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/fluid_drain_face" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/fluid_drain_side" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/fluid_metal_tank_side" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/fluid_metal_tank_top" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/fluid_pipe" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/fluid_switch_off" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/fluid_switch_on" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/item_conveyor_end" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/item_conveyor_side" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/item_conveyor_top" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/refined_oil_flow" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/refined_oil_still" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/steel_frame" + }, + { + "type": "minecraft:single", + "resource": "basepower:blocks/techreborn_converter" + }, + { + "type": "minecraft:single", + "resource": "basepower:materials/brass" + }, + { + "type": "minecraft:single", + "resource": "basepower:materials/iron" + }, + { + "type": "minecraft:single", + "resource": "basepower:materials/metal_panel" + }, + { + "type": "minecraft:single", + "resource": "basepower:materials/plastic" + }, + { + "type": "minecraft:single", + "resource": "basepower:materials/steel" + }, + { + "type": "minecraft:single", + "resource": "basepower:materials/steel_dark" + }, + { + "type": "minecraft:single", + "resource": "basepower:materials/wood" + } ] } diff --git a/src/main/resources/assets/minecraft/atlases/items.json b/src/main/resources/assets/minecraft/atlases/items.json index c2643eee..b2d8b2f3 100644 --- a/src/main/resources/assets/minecraft/atlases/items.json +++ b/src/main/resources/assets/minecraft/atlases/items.json @@ -1,5 +1,28 @@ { "sources": [ - { "type": "directory", "source": "items", "prefix": "items/" } + { + "type": "minecraft:single", + "resource": "basepower:items/bioplastic_ingot" + }, + { + "type": "minecraft:single", + "resource": "basepower:items/bucket_crude_oil" + }, + { + "type": "minecraft:single", + "resource": "basepower:items/bucket_refined_oil" + }, + { + "type": "minecraft:single", + "resource": "basepower:items/rotator_tool" + }, + { + "type": "minecraft:single", + "resource": "basepower:items/sprocket" + }, + { + "type": "minecraft:single", + "resource": "basepower:items/starch" + } ] } diff --git a/src/main/resources/basepower.mixins.json b/src/main/resources/basepower.mixins.json index e84dc9e1..861e5799 100644 --- a/src/main/resources/basepower.mixins.json +++ b/src/main/resources/basepower.mixins.json @@ -6,6 +6,7 @@ "mixins": [ "BlockStateDataMixin", "LevelStorageSourceMixin", + "SelectedWorldStorageMixin", "PlayerDataStorageMixin", "SimpleRegionStorageMixin", "WorldGenRegionMixin" diff --git a/src/main/resources/data/basepower/villager_trade/emerald_bioplastic.json b/src/main/resources/data/basepower/villager_trade/emerald_bioplastic.json new file mode 100644 index 00000000..ef9b1cc5 --- /dev/null +++ b/src/main/resources/data/basepower/villager_trade/emerald_bioplastic.json @@ -0,0 +1,13 @@ +{ + "wants": { "id": "minecraft:emerald", "count": 1 }, + "gives": { "id": "basepower:bioplastic_ingot" }, + "max_uses": 7, + "xp": 1, + "reputation_discount": 0, + "given_item_modifiers": [ + { + "function": "minecraft:set_count", + "count": { "type": "minecraft:uniform", "min": 4, "max": 8 } + } + ] +} diff --git a/src/main/resources/data/basepower/villager_trade/emerald_sprockets.json b/src/main/resources/data/basepower/villager_trade/emerald_sprockets.json new file mode 100644 index 00000000..5a58d546 --- /dev/null +++ b/src/main/resources/data/basepower/villager_trade/emerald_sprockets.json @@ -0,0 +1,13 @@ +{ + "wants": { "id": "minecraft:emerald", "count": 1 }, + "gives": { "id": "basepower:sprocket" }, + "max_uses": 7, + "xp": 1, + "reputation_discount": 0, + "given_item_modifiers": [ + { + "function": "minecraft:set_count", + "count": { "type": "minecraft:uniform", "min": 1, "max": 4 } + } + ] +} diff --git a/src/main/resources/data/minecraft/tags/villager_trade/farmer/level_1.json b/src/main/resources/data/minecraft/tags/villager_trade/farmer/level_1.json new file mode 100644 index 00000000..1de0be83 --- /dev/null +++ b/src/main/resources/data/minecraft/tags/villager_trade/farmer/level_1.json @@ -0,0 +1,4 @@ +{ + "replace": false, + "values": ["basepower:emerald_bioplastic"] +} diff --git a/src/main/resources/data/minecraft/tags/villager_trade/toolsmith/level_1.json b/src/main/resources/data/minecraft/tags/villager_trade/toolsmith/level_1.json new file mode 100644 index 00000000..a690cddd --- /dev/null +++ b/src/main/resources/data/minecraft/tags/villager_trade/toolsmith/level_1.json @@ -0,0 +1,4 @@ +{ + "replace": false, + "values": ["basepower:emerald_sprockets"] +} diff --git a/src/main/resources/pack.mcmeta b/src/main/resources/pack.mcmeta index 7a8d04dd..e48b0364 100644 --- a/src/main/resources/pack.mcmeta +++ b/src/main/resources/pack.mcmeta @@ -1,7 +1,7 @@ { "pack": { "description": "basePower resources", - "min_format": [94, 1], - "max_format": [94, 1] + "min_format": [101, 1], + "max_format": [101, 1] } } diff --git a/src/test/java/cyano/poweradvantage/api/migration/LegacyOreSpawnProfileMigrationTest.java b/src/test/java/cyano/poweradvantage/api/migration/LegacyOreSpawnProfileMigrationTest.java new file mode 100644 index 00000000..b6a920ce --- /dev/null +++ b/src/test/java/cyano/poweradvantage/api/migration/LegacyOreSpawnProfileMigrationTest.java @@ -0,0 +1,435 @@ +package cyano.poweradvantage.api.migration; + +import static org.junit.Assert.*; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import net.minecraft.resources.Identifier; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import zone.moddev.mc.orespawn.api.OreDimensionSelector; +import zone.moddev.mc.orespawn.api.OrePattern; +import zone.moddev.mc.orespawn.api.WorldgenProvider; + +public class LegacyOreSpawnProfileMigrationTest { + @Rule public final TemporaryFolder temporary = new TemporaryFolder(); + private static final String OLD = "poweradvantage:legacy/custom_oil"; + private static final String CURRENT = "basepower:fluid_deposit/desert_crude_oil"; + private static final Predicate TARGETS = Set.of("basepower:crude_oil", "basepower:refined_oil", + "baseelectric:li_ore", "baseelectric:sulfur_ore", "minecraft:nether_quartz_ore")::contains; + + private static JsonObject json(String value) { return JsonParser.parseString(value).getAsJsonObject(); } + + private static JsonObject profile() { + return json(""" + {"schema_version":7,"place_fluid_deposits":false,"manage_vanilla_ores":false, + "ores":{"poweradvantage:legacy/custom_oil":{"block":"poweradvantage:crude_oil", + "enabled":true,"retrogen":false,"source_provider":"poweradvantage", + "dimension_selectors":{"orespawn:all_except_nether_end":{"enabled":true, + "min_y":16,"max_y":63,"frequency":0.375,"min_quantity":14,"max_quantity":64, + "node_size":32,"vertical_spread":50,"host_blocks":["minecraft:stone"], + "biome_ids":["other:custom_biome"],"private":{"x":-987654321}}}}}, + "fluid_deposits":{},"history":"poweradvantage:crude_oil", + "providers":{"poweradvantage":{"known_ores":["poweradvantage:legacy/custom_oil"]}}, + "ore_source_policies":{"retained-group":{"outputs":{"poweradvantage:legacy/custom_oil":1}, + "placement_sources":{"orespawn:standard":"poweradvantage:legacy/custom_oil"}, + "candidates":[{"source_id":"poweradvantage:legacy/custom_oil","owner":"poweradvantage", + "registry_id":"poweradvantage:crude_oil"}]}}} + """); + } + + private static JsonObject powerDefaults() throws Exception { + try (var stream = LegacyOreSpawnProfileMigrationTest.class.getResourceAsStream("/data/basepower/orespawn/provider.json")) { + assertNotNull(stream); + return json(new String(stream.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + private static JsonObject electricDefaults() { + return typedElectricDefaults("dimensions"); + } + + private static JsonObject typedElectricDefaults(String field) { + return WorldgenProvider.builder("baseelectric", 1) + .ore(Identifier.parse("baseelectric:ore/lithium"), Identifier.parse("baseelectric:li_ore"), ore -> { + ore.retrogen(false); + if (field.equals("material")) ore.material(Identifier.parse("orespawn:lithium")); + if (field.equals("dimension_selectors")) { + ore.dimensionSelector(OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END, + LegacyOreSpawnProfileMigrationTest::lithiumDimension); + } else { + ore.dimension(Identifier.parse("minecraft:overworld"), + LegacyOreSpawnProfileMigrationTest::lithiumDimension); + } + }) + .ore(Identifier.parse("baseelectric:ore/sulfur"), Identifier.parse("baseelectric:sulfur_ore"), ore -> ore + .retrogen(false).dimension(Identifier.parse("minecraft:overworld"), dimension -> dimension + .yRange(1, 31).attempts(1).quantityRange(8, 23).pattern(OrePattern.DEFAULT) + .hostBlock(Identifier.parse("minecraft:stone")))) + .build().toJson(); + } + + private static void lithiumDimension(WorldgenProvider.OreDimensionDefinition.Builder dimension) { + dimension.yRange(1, 31).attempts(0.125).quantityRange(6, 9).pattern(OrePattern.DEFAULT) + .hostBlock(Identifier.parse("minecraft:stone")); + } + + private static JsonObject schemaFourCompatibleElectricDefaults() { + JsonObject defaults = electricDefaults(); + for (var entry : defaults.getAsJsonObject("ores").entrySet()) { + for (var placement : entry.getValue().getAsJsonObject().getAsJsonObject("dimensions").entrySet()) { + placement.getValue().getAsJsonObject().remove("placement_channel"); + } + } + return defaults; + } + + private static JsonObject legacyRule(JsonObject root) { return root.getAsJsonObject("ores").getAsJsonObject(OLD); } + private static JsonObject placement(JsonObject root) { + return legacyRule(root).getAsJsonObject("dimension_selectors").getAsJsonObject("orespawn:all_except_nether_end"); + } + + @Test public void preservesEveryNonOutputValueAndRulePolicyReference() throws Exception { + JsonObject source = profile(), original = source.deepCopy(), defaults = powerDefaults(), originalDefaults = defaults.deepCopy(); + var plan = LegacyOreSpawnProfileMigration.plan(source, defaults, TARGETS); + assertFalse(plan.getProblems().toString(), plan.isRefused()); + assertTrue(plan.isChanged()); + JsonObject result = plan.getJson(), expected = original.deepCopy(); + legacyRule(expected).addProperty("block", "basepower:crude_oil"); + expected.getAsJsonObject("ore_source_policies").getAsJsonObject("retained-group").getAsJsonArray("candidates") + .get(0).getAsJsonObject().addProperty("registry_id", "basepower:crude_oil"); + JsonObject guard = defaults.getAsJsonObject("fluid_deposits").getAsJsonObject(CURRENT).deepCopy(); + guard.addProperty("enabled", false); guard.addProperty("source_provider", "basepower"); + expected.getAsJsonObject("fluid_deposits").add(CURRENT, guard); + expected.getAsJsonObject("providers").add("basepower", json("{\"profile_defaults_applied\":true}")); + assertEquals(expected, result); + assertEquals(original, source); assertEquals(originalDefaults, defaults); + result.addProperty("history", "caller mutation"); + assertEquals(expected, plan.getJson()); + assertEquals("poweradvantage:legacy/custom_oil", plan.getJson().getAsJsonObject("ore_source_policies") + .getAsJsonObject("retained-group").getAsJsonObject("placement_sources").get("orespawn:standard").getAsString()); + } + + @Test public void sequentialOwnersDoNotConsumeEachOthersLegacyEvidenceOrWhitelist() throws Exception { + JsonObject source = profile(); + source.getAsJsonObject("ores").add("pack:arbitrary_rule", json(""" + {"block":"electricadvantage:li_ore","enabled":true, + "dimension_selectors":{"orespawn:all_except_nether_end":{"frequency":0.125,"node_size":8}}} + """)); + var power = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), + Set.of("basepower:crude_oil", "minecraft:nether_quartz_ore")::contains); + assertFalse(power.getProblems().toString(), power.isRefused()); + assertEquals("electricadvantage:li_ore", power.getJson().getAsJsonObject("ores").getAsJsonObject("pack:arbitrary_rule").get("block").getAsString()); + var electric = LegacyOreSpawnProfileMigration.plan(power.getJson(), electricDefaults(), TARGETS); + assertFalse(electric.getProblems().toString(), electric.isRefused()); + assertEquals("baseelectric:li_ore", electric.getJson().getAsJsonObject("ores").getAsJsonObject("pack:arbitrary_rule").get("block").getAsString()); + assertFalse(electric.getJson().getAsJsonObject("ores").getAsJsonObject("baseelectric:ore/lithium").get("enabled").getAsBoolean()); + assertFalse(LegacyOreSpawnProfileMigration.plan(electric.getJson(), powerDefaults(), TARGETS).isChanged()); + assertFalse(LegacyOreSpawnProfileMigration.plan(electric.getJson(), electricDefaults(), TARGETS).isChanged()); + } + + @Test public void disabledZeroRateAndDisabledDimensionStillSuppressMissingModernDefaults() throws Exception { + for (String mode : List.of("disabled", "zero", "dimension-disabled")) { + JsonObject source = profile(); + if (mode.equals("disabled")) legacyRule(source).addProperty("enabled", false); + if (mode.equals("zero")) placement(source).addProperty("frequency", 0); + if (mode.equals("dimension-disabled")) placement(source).addProperty("enabled", false); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertFalse(mode + plan.getProblems(), plan.isRefused()); + assertFalse(plan.getJson().getAsJsonObject("fluid_deposits").getAsJsonObject(CURRENT).get("enabled").getAsBoolean()); + assertEquals(placement(source), placement(plan.getJson())); + assertEquals(legacyRule(source).get("enabled"), legacyRule(plan.getJson()).get("enabled")); + } + } + + @Test public void activeOldAndCurrentOverlappingRulesRefuseEvenWhenEquivalent() throws Exception { + for (boolean equivalent : List.of(true, false)) { + JsonObject source = profile(); + JsonObject current = legacyRule(source).deepCopy(); + current.addProperty("block", "basepower:crude_oil"); + if (!equivalent) current.getAsJsonObject("dimension_selectors").getAsJsonObject("orespawn:all_except_nether_end").addProperty("frequency", 0.7); + source.getAsJsonObject("ores").add("pack:current_oil", current); + // Provenance stays literal; use non-legacy provenance for the independent current rule. + current.remove("source_provider"); legacyRule(source).remove("source_provider"); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertTrue(plan.isRefused()); + assertEquals(source, plan.getJson()); + assertTrue(plan.getProblems().toString().contains(equivalent ? "equivalent" : "conflicting")); + } + } + + @Test public void activeOreVersusActiveFluidDepositCollisionRefuses() throws Exception { + JsonObject source = profile(); source.addProperty("place_fluid_deposits", true); + source.getAsJsonObject("fluid_deposits").add(CURRENT, powerDefaults().getAsJsonObject("fluid_deposits").get(CURRENT).deepCopy()); + assertTrue(LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS).isRefused()); + } + + @Test public void currentDefaultIdWithStaleProviderCannotEvadeCollision() throws Exception { + JsonObject source = profile(); source.addProperty("place_fluid_deposits", true); + JsonObject current = powerDefaults().getAsJsonObject("fluid_deposits").getAsJsonObject(CURRENT).deepCopy(); + current.addProperty("source_provider", "poweradvantage"); + source.getAsJsonObject("fluid_deposits").add(CURRENT, current); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertTrue(plan.isRefused()); assertEquals(source, plan.getJson()); + assertTrue(plan.getProblems().toString().contains("active output collision")); + } + + @Test public void legacyOilSingletonRetainsDisabledMasterAndCoordinates() throws Exception { + JsonObject source = json(""" + {"schema_version":3,"place_crude_oil":false,"oil":{"block":"poweradvantage:crude_oil", + "min_y":-48,"max_y":36,"frequency":0.625,"min_radius":3,"max_radius":9}} + """); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertFalse(plan.getProblems().toString(), plan.isRefused()); + JsonObject result = plan.getJson(), expected = source.getAsJsonObject("oil").deepCopy(); + expected.addProperty("block", "basepower:crude_oil"); + assertEquals(expected, result.get("oil")); assertFalse(result.get("place_crude_oil").getAsBoolean()); + assertFalse(result.getAsJsonObject("fluid_deposits").getAsJsonObject(CURRENT).get("enabled").getAsBoolean()); + } + + @Test public void existingDisabledCurrentEntryAndItsEntirePayloadRemainUntouched() throws Exception { + JsonObject source = profile(), current = powerDefaults().getAsJsonObject("fluid_deposits").getAsJsonObject(CURRENT).deepCopy(); + current.addProperty("enabled", false); current.addProperty("user-note", "poweradvantage:crude_oil"); + source.getAsJsonObject("fluid_deposits").add(CURRENT, current); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertFalse(plan.isRefused()); assertEquals(current, plan.getJson().getAsJsonObject("fluid_deposits").get(CURRENT)); + } + + @Test public void disjointDimensionsAreNotTreatedAsDuplicatePlacements() throws Exception { + JsonObject source = profile(); + source.getAsJsonObject("ores").add("pack:nether_oil", json(""" + {"block":"basepower:crude_oil","enabled":true,"dimensions":{"minecraft:the_nether":{"frequency":1}}} + """)); + assertFalse(LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS).isRefused()); + } + + @Test public void knownWeightedDeepAndCachedOutputFieldsMigrateButOtherStringsDoNot() throws Exception { + JsonObject source = profile(), rule = legacyRule(source); + rule.addProperty("deep_output", "poweradvantage:refined_oil"); + rule.add("outputs", JsonParser.parseString("[{\"block\":\"poweradvantage:crude_oil\",\"weight\":0.3125,\"min_y\":-64,\"private\":\"poweradvantage:crude_oil\"}]")); + source.add("private", json("{\"block\":\"poweradvantage:crude_oil\",\"outputs\":[\"electricadvantage:li_ore\"]}")); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertFalse(plan.isRefused()); JsonObject after = legacyRule(plan.getJson()); + assertEquals("basepower:refined_oil", after.get("deep_output").getAsString()); + assertEquals("basepower:crude_oil", after.getAsJsonArray("outputs").get(0).getAsJsonObject().get("block").getAsString()); + assertEquals(rule.getAsJsonArray("outputs").get(0).getAsJsonObject().get("weight"), after.getAsJsonArray("outputs").get(0).getAsJsonObject().get("weight")); + assertEquals(source.get("private"), plan.getJson().get("private")); + assertEquals("poweradvantage:crude_oil", after.getAsJsonArray("outputs").get(0).getAsJsonObject().get("private").getAsString()); + } + + @Test public void schemasSevenEightAndExplicitWrappersPreserveMarkers() throws Exception { + for (int schema : List.of(7, 8)) for (String wrapper : List.of("flat", "profile", "worldgen", "geology", "geology_profile", "worldgen_profile")) { + JsonObject profile = profile(); profile.addProperty("schema_version", schema); + JsonObject source = wrapper.equals("flat") ? profile : new JsonObject(); + if (!wrapper.equals("flat")) source.add(wrapper, profile); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertFalse(plan.getProblems().toString(), plan.isRefused()); + JsonObject result = wrapper.equals("flat") ? plan.getJson() : plan.getJson().getAsJsonObject(wrapper); + assertEquals(schema, result.get("schema_version").getAsInt()); + assertEquals("basepower:crude_oil", legacyRule(result).get("block").getAsString()); + } + } + + @Test public void legacyProviderOverrideKeepsProviderAndRuleIdsWithoutForeignSeeds() throws Exception { + JsonObject source = profile(); source.addProperty("schema_version", 4); source.addProperty("provider_modid", "poweradvantage"); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertFalse(plan.isRefused()); assertEquals("poweradvantage", plan.getJson().get("provider_modid").getAsString()); + assertEquals(Set.of(OLD), plan.getJson().getAsJsonObject("ores").keySet()); + assertFalse(plan.getJson().getAsJsonObject("fluid_deposits").has(CURRENT)); + assertFalse(plan.getWarnings().isEmpty()); + } + + private static JsonObject electricOverride() { + return json(""" + {"schema_version":4,"provider_modid":"baseelectric","provider_revision":7, + "user":{"schema_version":4,"placement_channel":"leave:user_data"}, + "profile_defaults":{"manage_vanilla_ores":false},"rocks":{}, + "ores":{"baseelectric:legacy/saved_lithium":{"block":"electricadvantage:li_ore", + "enabled":false,"source_provider":"electricadvantage","private":{"keep":42}, + "dimension_selectors":{"orespawn:all_except_nether_end":{"enabled":true, + "frequency":0.25,"min_y":3,"max_y":27,"node_size":8}}}}} + """); + } + + @Test public void ownedSchemaFourPromotesOnlyWhenSeedAddsSchemaFiveOreFields() { + for (String field : List.of("material", "dimensions", "dimension_selectors")) { + JsonObject source = electricOverride(), original = source.deepCopy(); + JsonObject defaults = typedElectricDefaults(field), originalDefaults = defaults.deepCopy(); + assertEquals(5, defaults.get("schema_version").getAsInt()); + JsonObject typedRule = defaults.getAsJsonObject("ores").getAsJsonObject("baseelectric:ore/lithium"); + String placementSection = field.equals("dimension_selectors") ? field : "dimensions"; + assertTrue(typedRule.getAsJsonObject(placementSection).entrySet().iterator().next().getValue() + .getAsJsonObject().has("placement_channel")); + var plan = LegacyOreSpawnProfileMigration.plan(source, defaults, TARGETS); + assertFalse(field + plan.getProblems(), plan.isRefused()); + JsonObject expected = source.deepCopy(); + expected.addProperty("schema_version", 5); + expected.getAsJsonObject("ores").getAsJsonObject("baseelectric:legacy/saved_lithium") + .addProperty("block", "baseelectric:li_ore"); + JsonObject guard = defaults.getAsJsonObject("ores").getAsJsonObject("baseelectric:ore/lithium").deepCopy(); + guard.addProperty("enabled", false); guard.addProperty("source_provider", "baseelectric"); + expected.getAsJsonObject("ores").add("baseelectric:ore/lithium", guard); + assertEquals(field, expected, plan.getJson()); + assertTrue(plan.getChanges().toString(), plan.getChanges().stream() + .anyMatch(change -> change.contains("schema_version: 4 -> 5"))); + assertEquals(original, source); assertEquals(originalDefaults, defaults); + assertFalse(LegacyOreSpawnProfileMigration.plan(plan.getJson(), defaults, TARGETS).isChanged()); + } + } + + @Test public void schemaFiveDefaultsAloneDoNotPromoteSchemaFourOrUnownedProviders() { + JsonObject source = electricOverride(); + var plain = LegacyOreSpawnProfileMigration.plan(source, schemaFourCompatibleElectricDefaults(), TARGETS); + assertFalse(plain.isRefused()); assertEquals(4, plain.getJson().get("schema_version").getAsInt()); + JsonObject defaults = electricDefaults(); + source.getAsJsonObject("ores").add("baseelectric:ore/lithium", + schemaFourCompatibleElectricDefaults().getAsJsonObject("ores").get("baseelectric:ore/lithium").deepCopy()); + var existing = LegacyOreSpawnProfileMigration.plan(source, defaults, TARGETS); + assertFalse(existing.isRefused()); assertEquals(4, existing.getJson().get("schema_version").getAsInt()); + source = electricOverride(); source.addProperty("provider_modid", "electricadvantage"); + var foreign = LegacyOreSpawnProfileMigration.plan(source, defaults, TARGETS); + assertFalse(foreign.isRefused()); assertEquals(4, foreign.getJson().get("schema_version").getAsInt()); + assertFalse(foreign.getJson().getAsJsonObject("ores").has("baseelectric:ore/lithium")); + } + + @Test public void typedSchemaFiveSeedsKeepGlobalWorldAndWrapperVersionsUnchanged() { + for (int schema : List.of(5, 7, 8)) for (String wrapper : List.of("flat", "profile", "worldgen")) { + JsonObject profile = electricOverride(); profile.addProperty("schema_version", schema); + if (schema != 5) profile.remove("provider_modid"); + JsonObject source = wrapper.equals("flat") ? profile : new JsonObject(); + if (!wrapper.equals("flat")) source.add(wrapper, profile); + JsonObject defaults = electricDefaults(); + var plan = LegacyOreSpawnProfileMigration.plan(source, defaults, TARGETS); + assertFalse(plan.getProblems().toString(), plan.isRefused()); + JsonObject result = wrapper.equals("flat") ? plan.getJson() : plan.getJson().getAsJsonObject(wrapper); + assertEquals(schema, result.get("schema_version").getAsInt()); + JsonObject guard = defaults.getAsJsonObject("ores").getAsJsonObject("baseelectric:ore/lithium").deepCopy(); + guard.addProperty("enabled", false); guard.addProperty("source_provider", "baseelectric"); + assertEquals(guard, result.getAsJsonObject("ores").get("baseelectric:ore/lithium")); + } + } + + @Test public void schemaPromotionUsesExistingAtomicBackupReportAndIdempotence() throws Exception { + Path file = temporary.newFile("baseelectric-orespawn.json").toPath(); + Files.writeString(file, "\n" + electricOverride() + "\n"); + byte[] before = Files.readAllBytes(file); + JsonObject defaults = electricDefaults(); + var result = LegacyOreSpawnProfileMigration.migrate(file, defaults, TARGETS); + assertTrue(result.wasMigrated()); assertArrayEquals(before, Files.readAllBytes(result.backupFile())); + assertEquals(5, json(Files.readString(file)).get("schema_version").getAsInt()); + JsonObject report = json(Files.readString(result.reportFile())); + assertEquals("MIGRATED", report.get("status").getAsString()); + assertEquals(hash(before), report.get("source_sha256").getAsString()); + assertEquals(hash(Files.readAllBytes(file)), report.get("result_sha256").getAsString()); + byte[] after = Files.readAllBytes(file), receipt = Files.readAllBytes(result.reportFile()); + assertEquals(LegacyOreSpawnProfileMigration.Status.UNCHANGED, + LegacyOreSpawnProfileMigration.migrate(file, defaults, TARGETS).status()); + assertArrayEquals(after, Files.readAllBytes(file)); assertArrayEquals(receipt, Files.readAllBytes(result.reportFile())); + } + + @Test public void quartzImplicitRuleAndWeightedOutputUseVerifiedNativeNameOnly() throws Exception { + JsonObject source = json(""" + {"schema_version":8,"manage_vanilla_ores":false,"ores":{ + "minecraft:quartz_ore":{"enabled":true,"dimensions":{"minecraft:the_nether":{"frequency":7}}, + "outputs":[{"block":"minecraft:quartz_ore","weight":100}]}, + "minecraft:nether_quartz_ore":{"native_generation":true,"enabled":true,"dimensions":{"minecraft:the_nether":{"frequency":11.2}}}}} + """); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertFalse(plan.isRefused()); assertEquals(source.getAsJsonObject("ores").keySet(), plan.getJson().getAsJsonObject("ores").keySet()); + assertEquals("minecraft:nether_quartz_ore", plan.getJson().getAsJsonObject("ores").getAsJsonObject("minecraft:quartz_ore").get("block").getAsString()); + source.addProperty("manage_vanilla_ores", true); + assertTrue(LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS).isRefused()); + } + + @Test public void rootFlagsSurviveAndAbsentCurrentManifestOnlyGetsApplicationGuard() throws Exception { + JsonObject source = profile(); + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertFalse(plan.getJson().get("place_fluid_deposits").getAsBoolean()); + assertFalse(plan.getJson().get("manage_vanilla_ores").getAsBoolean()); + assertEquals(source.getAsJsonObject("providers").get("poweradvantage"), plan.getJson().getAsJsonObject("providers").get("poweradvantage")); + source.getAsJsonObject("providers").add("basepower", json("{\"profile_defaults_applied\":false,\"user\":1}")); + var refused = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS); + assertTrue(refused.isRefused()); assertEquals(source, refused.getJson()); + } + + @Test public void unavailableTargetsAndMalformedKnownFieldsRefuseWithoutPartialJson() throws Exception { + JsonObject source = profile(); + for (Predicate exists : List.>of(id -> false, id -> { throw new IllegalStateException("unbound"); })) { + var plan = LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), exists); + assertTrue(plan.isRefused()); assertEquals(source, plan.getJson()); + } + legacyRule(source).add("block", json("{}")); + assertTrue(LegacyOreSpawnProfileMigration.plan(source, powerDefaults(), TARGETS).isRefused()); + } + + @Test public void absentAndAlreadyCurrentDocumentsCreateNoEvidenceOrDirectories() throws Exception { + Path absent = temporary.getRoot().toPath().resolve("missing/serverconfig/orespawn-worldgen.json"); + assertEquals(LegacyOreSpawnProfileMigration.Status.MISSING, LegacyOreSpawnProfileMigration.migrate(absent, powerDefaults(), TARGETS).status()); + assertFalse(Files.exists(absent.getParent())); + Path file = temporary.newFile("current.json").toPath(); Files.writeString(file, "{ \"history\": \"poweradvantage:crude_oil\" }\n"); + byte[] original = Files.readAllBytes(file); + assertEquals(LegacyOreSpawnProfileMigration.Status.UNCHANGED, LegacyOreSpawnProfileMigration.migrate(file, powerDefaults(), TARGETS).status()); + assertArrayEquals(original, Files.readAllBytes(file)); assertEquals(1, temporary.getRoot().list().length); + } + + @Test public void diskMigrationBacksUpExactBytesReportsHashesAndRepeatsWithoutWrites() throws Exception { + Path file = temporary.newFile("world.json").toPath(); Files.writeString(file, "\n" + profile() + "\n"); + byte[] before = Files.readAllBytes(file); + var result = LegacyOreSpawnProfileMigration.migrate(file, powerDefaults(), TARGETS); + assertTrue(result.wasMigrated()); assertArrayEquals(before, Files.readAllBytes(result.backupFile())); + JsonObject report = json(Files.readString(result.reportFile())); + assertEquals("MIGRATED", report.get("status").getAsString()); + assertEquals(hash(before), report.get("source_sha256").getAsString()); + assertEquals(hash(Files.readAllBytes(file)), report.get("result_sha256").getAsString()); + byte[] migrated = Files.readAllBytes(file), receipt = Files.readAllBytes(result.reportFile()); + assertEquals(LegacyOreSpawnProfileMigration.Status.UNCHANGED, LegacyOreSpawnProfileMigration.migrate(file, powerDefaults(), TARGETS).status()); + assertArrayEquals(migrated, Files.readAllBytes(file)); assertArrayEquals(receipt, Files.readAllBytes(result.reportFile())); + assertEquals(3, temporary.getRoot().list().length); + } + + @Test public void refusedAttemptIsRetainedAndDoesNotBlockLaterValidAttempt() throws Exception { + Path file = temporary.newFile("profile.json").toPath(); Files.writeString(file, profile().toString()); + byte[] before = Files.readAllBytes(file); + var refused = LegacyOreSpawnProfileMigration.migrate(file, powerDefaults(), id -> false); + assertEquals(LegacyOreSpawnProfileMigration.Status.REFUSED, refused.status()); + byte[] refusal = Files.readAllBytes(refused.reportFile()); assertNull(refused.backupFile()); + assertArrayEquals(before, Files.readAllBytes(file)); + var green = LegacyOreSpawnProfileMigration.migrate(file, powerDefaults(), TARGETS); + assertTrue(green.wasMigrated()); assertNotEquals(refused.reportFile(), green.reportFile()); + assertArrayEquals(refusal, Files.readAllBytes(refused.reportFile())); + } + + @Test public void malformedDuplicateAndInvalidUtf8InputsNeverChangeSource() throws Exception { + for (String raw : List.of("{\"ores\":{},\"ores\":{}}", "{\"ores\":{}} trailing", "{\"x\":NaN}", "[]")) { + Path file = temporary.newFile().toPath(); Files.writeString(file, raw); + try { LegacyOreSpawnProfileMigration.migrate(file, powerDefaults(), TARGETS); fail(raw); } + catch (java.io.IOException expected) { assertEquals(raw, Files.readString(file)); } + } + Path bad = temporary.newFile().toPath(); byte[] invalid = {(byte) 0xC3, (byte) 0x28}; Files.write(bad, invalid); + try { LegacyOreSpawnProfileMigration.migrate(bad, powerDefaults(), TARGETS); fail(); } + catch (java.io.IOException expected) { assertArrayEquals(invalid, Files.readAllBytes(bad)); } + } + + @Test public void conflictingBackupNeverGetsOverwritten() throws Exception { + Path file = temporary.newFile("profile.json").toPath(); Files.writeString(file, profile().toString()); + byte[] source = Files.readAllBytes(file); + Path backup = file.resolveSibling(file.getFileName() + ".advantage-output-" + hash(source) + ".bak"); + Files.writeString(backup, "protected evidence"); + try { LegacyOreSpawnProfileMigration.migrate(file, powerDefaults(), TARGETS); fail(); } + catch (java.io.IOException expected) { assertTrue(expected.getMessage().contains("Conflicting migration backup")); } + assertEquals("protected evidence", Files.readString(backup)); assertArrayEquals(source, Files.readAllBytes(file)); + } + + private static String hash(byte[] bytes) throws Exception { + return HexFormat.of().withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } +} diff --git a/src/test/java/cyano/poweradvantage/api/simple/SimpleMachineGuiNativeTest.java b/src/test/java/cyano/poweradvantage/api/simple/SimpleMachineGuiNativeTest.java index 84c4a29e..b0c57770 100644 --- a/src/test/java/cyano/poweradvantage/api/simple/SimpleMachineGuiNativeTest.java +++ b/src/test/java/cyano/poweradvantage/api/simple/SimpleMachineGuiNativeTest.java @@ -1,15 +1,13 @@ package cyano.poweradvantage.api.simple; -import java.lang.reflect.Field; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import net.minecraft.SharedConstants; import net.minecraft.client.gui.Font; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.render.state.GuiRenderState; -import net.minecraft.client.gui.render.state.GuiTextRenderState; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.state.gui.BlitRenderState; +import net.minecraft.client.renderer.state.gui.GuiRenderState; +import net.minecraft.client.renderer.state.gui.GuiTextRenderState; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.server.Bootstrap; @@ -22,7 +20,9 @@ import org.junit.BeforeClass; import org.junit.Test; import zone.moddev.mc.basepower.math.Integer2D; +import zone.moddev.mc.basepower.resources.NativeGuiExtractionFixture; import static org.junit.Assert.*; +import static zone.moddev.mc.basepower.resources.NativeGuiExtractionFixture.*; /** Native extraction/layout contracts, not GPU glyph or whole-panel screenshot proof. */ public class SimpleMachineGuiNativeTest { @@ -65,7 +65,7 @@ public class SimpleMachineGuiNativeTest { assertSame(fixture.graphics, fixture.screen.getGraphics()); assertSame(fixture.pose, fixture.screen.getMatrices()); assertEquals(Matrix3x2fStack.class, SimpleMachineGUI.GUIContainer.class.getMethod("getMatrices").getReturnType()); - assertEquals(GuiGraphics.class, SimpleMachineGUI.GUIContainer.class.getMethod("getGraphics").getReturnType()); + assertEquals(GuiGraphicsExtractor.class, SimpleMachineGUI.GUIContainer.class.getMethod("getGraphics").getReturnType()); for (float value : new float[] {0, 0.5F, 1}) { var pose = fixture.screen.getMatrices(); var before = new Matrix3x2f(pose); @@ -88,7 +88,7 @@ public class SimpleMachineGuiNativeTest { @Test public void captionsReachNativeTextExtractionWithOpaqueWhiteAndSeparateRows() throws Exception { var fixture = screen("Machine", "Inventory", 854); fixture.pose.translate(13, 29); - fixture.screen.renderLabels(fixture.graphics, 0, 0); + fixture.screen.extractLabels(fixture.graphics, 0, 0); assertEquals(2, fixture.target.texts.size()); var title = fixture.target.texts.get(0); var inventory = fixture.target.texts.get(1); @@ -111,7 +111,7 @@ public class SimpleMachineGuiNativeTest { @Test public void longCaptionsKeepTheirAcceptedWidthLimitsAndEllipsis() throws Exception { var fixture = screen("M".repeat(100), "I".repeat(100), 200); - fixture.screen.renderLabels(fixture.graphics, 0, 0); + fixture.screen.extractLabels(fixture.graphics, 0, 0); assertEquals(2, fixture.target.texts.size()); assertEquals("M".repeat(29) + "...", text(fixture.target.texts.get(0))); assertEquals("I".repeat(23) + "...", text(fixture.target.texts.get(1))); @@ -119,40 +119,115 @@ public class SimpleMachineGuiNativeTest { assertEquals(8, fixture.target.texts.get(1).x); } - @Test public void backgroundAndTooltipUseNativeExtractionWithoutGlobalRenderState() throws Exception { - String source = Files.readString(Path.of("src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java")); - assertTrue(source.contains("graphics.setTooltipForNextFrame(font, title, mouseX, mouseY)")); - assertTrue(source.contains("graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256)")); - assertTrue(source.contains("blit(playerInventoryBox, x, y + PLAYER_PANEL_TOP, 0, 132, playerInventoryWidth, playerInventoryHeight)")); - assertTrue(source.contains("blit(x, y + MACHINE_TOP, 0, 0, guiWidth, MACHINE_HEIGHT)")); - assertTrue(source.contains("drawGUIDecorations(entity, this, x, y + MACHINE_TOP, getZLevel())")); - assertTrue(source.contains("this.graphics = null")); - assertFalse(source.contains("RenderSystem")); - assertFalse(source.contains("graphics.renderTooltip(font, title")); - SimpleMachineGUI.GUIContainer.class.getMethod("blit", int.class, int.class, int.class, int.class, int.class, int.class); - SimpleMachineGUI.GUIContainer.class.getMethod("blit", Identifier.class, int.class, int.class, int.class, int.class, int.class, int.class); + @Test public void nativeScreenWrapperExtractsBackgroundOnceAndReleasesDecorationContext() throws Exception { + var fixture = screen("Machine", "Inventory", 854); + set(fixture.screen, "graphics", null); + int[] decorations = {0}; + fixture.gui.decoration = (screen, x, y) -> { + decorations[0]++; + assertSame(fixture.graphics, screen.getGraphics()); + assertSame(fixture.pose, screen.getMatrices()); + assertEquals(339, x); + assertEquals(132, y); + }; + fixture.screen.extractRenderStateWithTooltipAndSubtitles(fixture.graphics, -100, -100, 0F); + assertEquals(1, fixture.screen.backgroundCalls); + assertEquals(1, decorations[0]); + assertNull(fixture.screen.getGraphics()); + var blits = new ArrayList(); + fixture.target.forEachElement(state -> { + if (state instanceof BlitRenderState blit) blits.add(blit); + }, GuiRenderState.TraverseRange.ALL); + assertEquals(2, blits.size()); + var panel = blits.stream().filter(state -> state.y0() == 270).findFirst().orElseThrow(); + var machine = blits.stream().filter(state -> state.y0() == 132).findFirst().orElseThrow(); + assertEquals(339, panel.x0()); + assertEquals(515, panel.x1()); + assertEquals(360, panel.y1()); + assertEquals(132F / 256F, panel.v0(), 0F); + assertEquals(222F / 256F, panel.v1(), 0F); + assertEquals(339, machine.x0()); + assertEquals(515, machine.x1()); + assertEquals(260, machine.y1()); + assertEquals(0F, machine.v0(), 0F); + assertEquals(0.5F, machine.v1(), 0F); + assertNotSame(panel.textureSetup().texure0(), machine.textureSetup().texure0()); + assertEquals(2, fixture.target.texts.size()); + } + + @Test public void failedDecorationCannotLeakActiveExtractor() throws Exception { + var fixture = screen("Machine", "Inventory", 854); + var failure = new IllegalStateException("deliberate decoration failure"); + fixture.gui.decoration = (screen, x, y) -> { throw failure; }; + assertSame(failure, assertThrows(IllegalStateException.class, + () -> fixture.screen.extractBackground(fixture.graphics, -100, -100, 0F))); + assertNull(fixture.screen.getGraphics()); + } + + @Test public void longTitleSchedulesNativeDeferredTooltipOnlyInTheTitleRow() throws Exception { + var fixture = screen("M".repeat(100), "Inventory", 200); + fixture.screen.extractRenderState(fixture.graphics, 50, 121, 0F); + assertNotNull(get(fixture.graphics, "deferredTooltip")); + set(fixture.graphics, "deferredTooltip", null); + fixture.screen.extractRenderState(fixture.graphics, 50, 132, 0F); + assertNull(get(fixture.graphics, "deferredTooltip")); + set(fixture.screen, "title", Component.literal("Machine")); + fixture.screen.extractRenderState(fixture.graphics, 50, 121, 0F); + assertNull(get(fixture.graphics, "deferredTooltip")); } private static Fixture screen(String title, String inventory, int width) throws Exception { var target = new TextQueue(); - var pose = new Matrix3x2fStack(16); - var graphics = allocate(GuiGraphics.class); - set(graphics, "pose", pose); - set(graphics, "guiRenderState", target); - set(graphics, "scissorStack", new GuiGraphics.ScissorStack()); - var screen = allocate(SimpleMachineGUI.GUIContainer.class); + var extraction = new NativeGuiExtractionFixture(target); + var graphics = extraction.graphics; + var gui = new RecordingGui(); + extraction.texture(gui.guiDisplayImage); + extraction.texture(SimpleMachineGUI.playerInventoryBox); + var screen = allocate(RecordingScreen.class); var font = new MetricsFont(); + set(screen, "this$0", gui); set(screen, "graphics", graphics); + set(screen, "minecraft", extraction.minecraft); + var inventoryItems = new Inventory(null, new EntityEquipment()); + set(screen, "menu", gui.createMenu(1, inventoryItems, new SimpleContainer(0))); + set(screen, "draggingItem", net.minecraft.world.item.ItemStack.EMPTY); + set(screen, "renderables", List.of()); set(screen, "playerInventoryLabel", Component.literal(inventory)); set(screen, "title", Component.literal(title)); set(screen, "font", font); set(screen, "width", width); + set(screen, "height", 480); set(screen, "imageWidth", 176); - return new Fixture(screen, graphics, target, pose, font); + set(screen, "imageHeight", 240); + set(screen, "topPos", 120); + return new Fixture(screen, graphics, target, extraction.pose, font, gui); } - private record Fixture(SimpleMachineGUI.GUIContainer screen, GuiGraphics graphics, - TextQueue target, Matrix3x2fStack pose, MetricsFont font) {} + private record Fixture(RecordingScreen screen, GuiGraphicsExtractor graphics, + TextQueue target, Matrix3x2fStack pose, MetricsFont font, RecordingGui gui) {} + + @FunctionalInterface private interface Decoration { + void extract(SimpleMachineGUI.GUIContainer screen, int x, int y); + } + + private static final class RecordingGui extends SimpleMachineGUI { + Decoration decoration = (screen, x, y) -> {}; + RecordingGui() { super(Identifier.parse("basepower:textures/gui/container/fluid_tank_gui.png")); } + @Override public void drawGUIDecorations(Object entity, GUIContainer screen, int x, int y, float z) { + decoration.extract(screen, x, y); + } + } + + private static final class RecordingScreen extends SimpleMachineGUI.GUIContainer { + int backgroundCalls; + RecordingScreen(SimpleMachineGUI gui, SimpleMachineGUI.Container menu, Inventory items) { + gui.super(menu, items, Component.literal("Machine")); + } + @Override public void extractBackground(GuiGraphicsExtractor graphics, int x, int y, float delta) { + backgroundCalls++; + super.extractBackground(graphics, x, y, delta); + } + } private static final class MetricsFont extends Font { MetricsFont() { super(null); } @@ -165,7 +240,7 @@ private static final class MetricsFont extends Font { // Capture real native text states before GPU glyph preparation; no font assets are fabricated. private static final class TextQueue extends GuiRenderState { final List texts = new ArrayList<>(); - @Override public void submitText(GuiTextRenderState text) { texts.add(text); } + @Override public void addText(GuiTextRenderState text) { texts.add(text); } } private static String text(GuiTextRenderState state) { @@ -174,22 +249,4 @@ private static String text(GuiTextRenderState state) { return text.toString(); } - private static T allocate(Class type) throws Exception { - Class allocator = Class.forName("sun.misc.Unsafe"); - Field instance = allocator.getDeclaredField("theUnsafe"); - instance.setAccessible(true); - return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(instance.get(null), type)); - } - - private static void set(Object instance, String name, Object value) throws Exception { - for (Class type = instance.getClass(); type != null; type = type.getSuperclass()) { - try { - Field field = type.getDeclaredField(name); - field.setAccessible(true); - field.set(instance, value); - return; - } catch (NoSuchFieldException ignored) {} - } - throw new NoSuchFieldException(name); - } } diff --git a/src/test/java/net/minecraftforge/registries/LegacyOreSpawnRegistryAliasesTest.java b/src/test/java/net/minecraftforge/registries/LegacyOreSpawnRegistryAliasesTest.java new file mode 100644 index 00000000..e6d91907 --- /dev/null +++ b/src/test/java/net/minecraftforge/registries/LegacyOreSpawnRegistryAliasesTest.java @@ -0,0 +1,178 @@ +package net.minecraftforge.registries; + +import static org.junit.Assert.*; + +import java.util.Set; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import org.junit.Test; +import zone.moddev.mc.basepower.migration.LegacyOreSpawnRegistryAliases; + +/** Package access creates isolated native registries without reflection or global ACTIVE changes. */ +public class LegacyOreSpawnRegistryAliasesTest { + private static final Identifier OLD = Identifier.parse("minecraft:quartz_ore"); + private static final Identifier CURRENT = Identifier.parse("minecraft:nether_quartz_ore"); + private static final Identifier OTHER = Identifier.parse("test:other_block"); + + private static ForgeRegistry registry() { + Identifier name = Identifier.parse("test:legacy_ore_aliases"); + return new RegistryManager("LEGACY_ORE_ALIAS_TEST").createRegistry(name, + new RegistryBuilder().setName(name)); + } + + private static void install(ForgeRegistry registry) { + LegacyOreSpawnRegistryAliases.register(new RegisterEvent(Registries.BLOCK, registry, null)); + } + + private static void assertSnapshotUnchanged(ForgeRegistry registry, CompoundTag before) { + assertEquals(before, registry.makeSnapshot().write()); + } + + @Test public void oldLookupKeepsCanonicalNameNumericIdAndEntryCount() { + ForgeRegistry registry = registry(); + Object quartz = new Object(), other = new Object(); + registry.register(17, CURRENT, quartz); + registry.register(29, OTHER, other); + Set keys = Set.copyOf(registry.getKeys()); + var ids = registry.makeSnapshot().ids; + int count = registry.getValues().size(); + assertNull(registry.getRaw(OLD)); + install(registry); + assertSame(quartz, registry.getValue(OLD)); + assertSame(quartz, registry.getRaw(OLD)); + assertEquals(CURRENT, registry.getKey(registry.getValue(OLD))); + assertEquals(17, registry.getID(quartz)); + assertEquals(17, registry.getID(CURRENT)); + assertEquals(-1, registry.getID(OLD)); + assertEquals(keys, registry.getKeys()); + assertFalse(registry.getKeys().contains(OLD)); + assertEquals(count, registry.getValues().size()); + assertEquals(ids, registry.makeSnapshot().ids); + assertEquals(CURRENT, registry.makeSnapshot().aliases.get(OLD)); + } + + @Test public void repeatedInstallIsIdempotentEvenAfterRegistryFreeze() { + ForgeRegistry registry = registry(); + registry.register(17, CURRENT, new Object()); + install(registry); + CompoundTag first = registry.makeSnapshot().write(); + install(registry); + assertSnapshotUnchanged(registry, first); + registry.freeze(); + install(registry); + assertSnapshotUnchanged(registry, first); + } + + @Test public void nativeSnapshotRoundTripPersistsAliasWithoutAddingAnId() { + ForgeRegistry registry = registry(); + Object quartz = new Object(); + registry.register(17, CURRENT, quartz); + install(registry); + var snapshot = registry.makeSnapshot(); + var decoded = ForgeRegistry.Snapshot.read(snapshot.write()); + assertEquals(snapshot.ids, decoded.ids); + assertEquals(snapshot.aliases, decoded.aliases); + assertEquals(snapshot.overrides, decoded.overrides); + assertEquals(snapshot.blocked, decoded.blocked); + assertFalse(decoded.ids.containsKey(OLD)); + ForgeRegistry restored = registry(); + for (var entry : decoded.ids.object2IntEntrySet()) restored.register(entry.getIntValue(), entry.getKey(), quartz); + decoded.aliases.forEach(restored::addAlias); + assertSame(quartz, restored.getValue(OLD)); + assertEquals(CURRENT, restored.getKey(restored.getValue(OLD))); + assertEquals(17, restored.getID(quartz)); + assertEquals(1, restored.getValues().size()); + } + + @Test public void conflictingCanonicalEntryIsRefusedWithoutChangingRegistry() { + ForgeRegistry registry = registry(); + registry.register(17, CURRENT, new Object()); + registry.register(29, OLD, new Object()); + assertRefused(registry, "Conflicting"); + } + + @Test public void conflictingExistingAliasIsRefusedWithoutChangingRegistry() { + ForgeRegistry registry = registry(); + registry.register(17, CURRENT, new Object()); + registry.register(29, OTHER, new Object()); + registry.addAlias(OLD, OTHER); + assertRefused(registry, "Conflicting"); + } + + @Test public void danglingExistingAliasIsNotSilentlyOverwritten() { + ForgeRegistry registry = registry(); + registry.register(17, CURRENT, new Object()); + registry.addAlias(OLD, OTHER); + assertNull(registry.getRaw(OLD)); + assertRefused(registry, "Conflicting"); + } + + @Test(timeout = 5000) public void cyclicOldAliasesRefuseWithoutLookupHangOrMutation() { + for (boolean longerCycle : new boolean[] {false, true}) { + ForgeRegistry registry = registry(); + registry.register(17, CURRENT, new Object()); + registry.addAlias(OLD, OTHER); + if (longerCycle) { + Identifier third = Identifier.parse("test:third"); + registry.addAlias(OTHER, third); + registry.addAlias(third, OLD); + assertEquals(OLD, registry.makeSnapshot().aliases.get(third)); + } else registry.addAlias(OTHER, OLD); + assertEquals(OTHER, registry.makeSnapshot().aliases.get(OLD)); + assertRefused(registry, "cycle"); + } + } + + @Test public void validAliasChainIsAcceptedWithoutRewritingOrAllocatingEntries() { + ForgeRegistry registry = registry(); + Object quartz = new Object(); + registry.register(17, CURRENT, quartz); + registry.addAlias(OLD, OTHER); + registry.addAlias(OTHER, CURRENT); + CompoundTag before = registry.makeSnapshot().write(); + install(registry); + registry.freeze(); + install(registry); + assertSnapshotUnchanged(registry, before); + assertSame(quartz, registry.getValue(OLD)); + assertEquals(CURRENT, registry.getKey(registry.getValue(OLD))); + assertEquals(17, registry.getID(quartz)); + assertEquals(Set.of(CURRENT), registry.getKeys()); + assertEquals(1, registry.getValues().size()); + } + + @Test(timeout = 5000) public void missingOrNoncanonicalTargetIsRefusedWithoutAddingAlias() { + ForgeRegistry empty = registry(); + assertRefused(empty, "Missing canonical"); + ForgeRegistry aliasTarget = registry(); + aliasTarget.register(29, OTHER, new Object()); + aliasTarget.addAlias(CURRENT, OTHER); + assertRefused(aliasTarget, "Missing canonical"); + ForgeRegistry cyclicTarget = registry(); + cyclicTarget.addAlias(CURRENT, OTHER); + cyclicTarget.addAlias(OTHER, CURRENT); + assertRefused(cyclicTarget, "Missing canonical"); + } + + @Test public void wrongRegistryEventIsIgnoredAndFrozenFreshInstallRefuses() { + ForgeRegistry registry = registry(); + registry.register(17, CURRENT, new Object()); + CompoundTag before = registry.makeSnapshot().write(); + LegacyOreSpawnRegistryAliases.register(new RegisterEvent(Registries.ITEM, registry, null)); + assertSnapshotUnchanged(registry, before); + registry.freeze(); + assertRefused(registry, "too late"); + } + + private static void assertRefused(ForgeRegistry registry, String message) { + CompoundTag before = registry.makeSnapshot().write(); + try { + install(registry); + fail("Expected registry alias refusal: " + message); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains(message)); + } + assertSnapshotUnchanged(registry, before); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/gui/FluidTankGuiNativeTest.java b/src/test/java/zone/moddev/mc/basepower/gui/FluidTankGuiNativeTest.java index 83455884..5a201952 100644 --- a/src/test/java/zone/moddev/mc/basepower/gui/FluidTankGuiNativeTest.java +++ b/src/test/java/zone/moddev/mc/basepower/gui/FluidTankGuiNativeTest.java @@ -3,12 +3,15 @@ import java.util.ArrayList; import java.util.List; import net.minecraft.client.gui.navigation.ScreenRectangle; -import net.minecraft.client.gui.render.TextureSetup; -import net.minecraft.client.gui.render.state.BlitRenderState; -import net.minecraft.client.gui.render.state.GuiRenderState; +import net.minecraft.client.renderer.state.gui.BlitRenderState; +import net.minecraft.client.renderer.state.gui.GuiElementRenderState; +import net.minecraft.client.renderer.state.gui.GuiRenderState; import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.resources.Identifier; import org.joml.Matrix3x2f; import org.junit.Test; +import zone.moddev.mc.basepower.client.FluidGaugeRenderer; +import zone.moddev.mc.basepower.resources.NativeGuiExtractionFixture; import static org.junit.Assert.*; /** Real native extraction state geometry, not texture upload or screenshot qualification. */ @@ -27,34 +30,38 @@ public class FluidTankGuiNativeTest { assertTrue(source.contains("guiContainer.blit(displayImage, x + xPos - 3, y + yPos - 3, 176, 0, 22, 66)")); } - @Test public void fullGaugeQueuesFourNativeQuadsAndCropsTheLastTileUv() { + @Test public void fullGaugeQueuesFullNativeTilesAndScissorCropsTheLastRow() throws Exception { var states = extract(new Matrix3x2f(), null, 80, 9, 16, 60, 0x22123456); assertEquals(4, states.size()); for (int row = 0; row < 4; row++) { var tile = at(states, 80, 9 + row * 16); assertEquals(96, tile.x1()); - assertEquals(9 + Math.min(60, (row + 1) * 16), tile.y1()); + assertEquals(9 + (row + 1) * 16, tile.y1()); assertEquals(0.25F, tile.u0(), 0F); assertEquals(0.5F, tile.u1(), 0F); assertEquals(0.5F, tile.v0(), 0F); - assertEquals(row == 3 ? 0.6875F : 0.75F, tile.v1(), 0F); + assertEquals(0.75F, tile.v1(), 0F); + assertEquals(new ScreenRectangle(80, 9, 16, 60), tile.scissorArea()); + assertEquals(new ScreenRectangle(80, 9 + row * 16, 16, row == 3 ? 12 : 16), tile.bounds()); assertSame(RenderPipelines.GUI_TEXTURED, tile.pipeline()); assertEquals(0xFF123456, tile.color()); } } - @Test public void widePartialRectanglePreservesTileGeometryAndBothUvFractions() { + @Test public void widePartialRectangleClipsFullTilesWithoutStretchingEitherAxis() throws Exception { var states = extract(new Matrix3x2f(), null, 3, 7, 19, 17, 0x3F76E4); assertEquals(4, states.size()); var corner = at(states, 19, 23); - assertEquals(22, corner.x1()); - assertEquals(24, corner.y1()); - assertEquals(0.25F + 0.25F * 3 / 16, corner.u1(), 0F); - assertEquals(0.5F + 0.25F / 16, corner.v1(), 0F); + assertEquals(35, corner.x1()); + assertEquals(39, corner.y1()); + assertEquals(0.5F, corner.u1(), 0F); + assertEquals(0.75F, corner.v1(), 0F); + assertEquals(new ScreenRectangle(3, 7, 19, 17), corner.scissorArea()); + assertEquals(new ScreenRectangle(19, 23, 3, 1), corner.bounds()); assertEquals(0xFF3F76E4, corner.color()); } - @Test public void extractionSnapshotsTransformsAndUsesNativeScissorBounds() { + @Test public void extractionSnapshotsTransformsAndIntersectsParentScissorBounds() throws Exception { var pose = new Matrix3x2f().translation(10, 20); var scissor = new ScreenRectangle(11, 22, 5, 6); var states = extract(pose, scissor, 0, 0, 16, 16, 0xFFFFFFFF); @@ -66,18 +73,41 @@ public class FluidTankGuiNativeTest { assertEquals(scissor, state.bounds()); } - @Test public void emptyAndInvalidRectanglesSubmitNothing() { + @Test public void emptyAndInvalidRectanglesSubmitNothing() throws Exception { assertTrue(extract(new Matrix3x2f(), null, 0, 0, 0, 60, -1).isEmpty()); assertTrue(extract(new Matrix3x2f(), null, 0, 0, 16, 0, -1).isEmpty()); assertTrue(extract(new Matrix3x2f(), null, 0, 0, -1, 60, -1).isEmpty()); assertTrue(extract(new Matrix3x2f(), null, 0, 0, 16, -1, -1).isEmpty()); } + @Test public void nativeScissorIsRestoredEvenWhenQueuingThrows() throws Exception { + var failure = new IllegalStateException("deliberate queue failure"); + var target = new GuiRenderState() { + @Override public void addGuiElement(GuiElementRenderState state) { throw failure; } + }; + var fixture = new NativeGuiExtractionFixture(target); + var sprite = fixture.sprite(Identifier.parse("basepower:cpu_geometry_atlas")); + fixture.graphics.enableScissor(1, 2, 21, 22); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> FluidGaugeRenderer.extractFluidTiles(fixture.graphics, sprite, 3, 4, 5, 6, -1))); + assertTrue(fixture.graphics.containsPointInScissor(2, 3)); + assertFalse(fixture.graphics.containsPointInScissor(0, 3)); + fixture.graphics.disableScissor(); + assertTrue(fixture.graphics.containsPointInScissor(0, 3)); + assertThrows(IllegalStateException.class, fixture.graphics::disableScissor); + } + private static List extract(Matrix3x2f pose, ScreenRectangle scissor, - int x, int y, int width, int height, int color) { + int x, int y, int width, int height, int color) throws Exception { var target = new GuiRenderState(); - FluidTankGUI.extractFluidTiles(target, TextureSetup.noTexture(), pose, scissor, - x, y, width, height, 0.25F, 0.5F, 0.5F, 0.75F, color); + var fixture = new NativeGuiExtractionFixture(target); + if (scissor != null) fixture.graphics.enableScissor(scissor.left(), scissor.top(), scissor.right(), scissor.bottom()); + fixture.pose.set(pose); + var sprite = fixture.sprite(Identifier.parse("basepower:cpu_geometry_atlas")); + FluidGaugeRenderer.extractFluidTiles(fixture.graphics, sprite, x, y, width, height, color); + fixture.pose.translate(100, 100); + if (scissor != null) fixture.graphics.disableScissor(); + assertThrows(IllegalStateException.class, fixture.graphics::disableScissor); var states = new ArrayList(); target.forEachElement(state -> states.add((BlitRenderState) state), GuiRenderState.TraverseRange.ALL); return states; diff --git a/src/test/java/zone/moddev/mc/basepower/migration/CooperativeChunkReturnHooksTest.java b/src/test/java/zone/moddev/mc/basepower/migration/CooperativeChunkReturnHooksTest.java index 07de1ac9..5e9270c0 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/CooperativeChunkReturnHooksTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/CooperativeChunkReturnHooksTest.java @@ -72,8 +72,12 @@ class FixtureLoader extends ClassLoader { Constructor constructor = type.getDeclaredConstructor(); constructor.setAccessible(true); Method handler = type.getDeclaredMethod(mod + "$finishChunk", CompoundTag.class, int.class, - CompoundTag.class, CallbackInfoReturnable.class); + CompoundTag.class, int.class, CallbackInfoReturnable.class); + assertThrows("The removed three-argument chunk ABI must not satisfy this test", NoSuchMethodException.class, + () -> type.getDeclaredMethod(mod + "$finishChunk", CompoundTag.class, int.class, + CompoundTag.class, CallbackInfoReturnable.class)); handler.setAccessible(true); - handler.invoke(constructor.newInstance(), root, 0, null, callback); + handler.invoke(constructor.newInstance(), root, 0, null, + net.minecraft.SharedConstants.getCurrentVersion().dataVersion().version(), callback); } } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LateOreSpawnProfileMigrationTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LateOreSpawnProfileMigrationTest.java new file mode 100644 index 00000000..4df6aee8 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/migration/LateOreSpawnProfileMigrationTest.java @@ -0,0 +1,214 @@ +package zone.moddev.mc.basepower.migration; + +import static org.junit.Assert.*; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.objectweb.asm.Handle; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.InvokeDynamicInsnNode; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.analysis.Analyzer; +import org.objectweb.asm.tree.analysis.Frame; +import org.objectweb.asm.tree.analysis.SourceInterpreter; +import org.objectweb.asm.tree.analysis.SourceValue; + +public class LateOreSpawnProfileMigrationTest { + @Rule public final TemporaryFolder temporary = new TemporaryFolder(); + + private Path registerBeforeImport(String name) throws Exception { + AdvantageLegacyMigrations.registerPowerAdvantage(); + Path config = temporary.newFolder(name).toPath(); + OreSpawnProfileMigration.registerPowerProvider(config); + try (var files = Files.list(config)) { assertEquals(0, files.count()); } + return config; + } + + private static JsonObject quartzProfile(int schema) { + return JsonParser.parseString(""" + {"schema_version":%d,"manage_vanilla_ores":false,"place_fluid_deposits":false, + "ores":{"minecraft:quartz_ore":{"enabled":true,"suppress_vanilla":false, + "dimensions":{"minecraft:the_nether":{"enabled":true,"frequency":0.375, + "min_y":12,"max_y":117,"node_size":9,"private":{"keep":42}}}}}, + "providers":{"retained":{"known_ores":["minecraft:quartz_ore"]}}, + "history":"minecraft:quartz_ore is a literal note"} + """.formatted(schema)).getAsJsonObject(); + } + + private static JsonObject read(Path file) throws IOException { + return JsonParser.parseString(Files.readString(file)).getAsJsonObject(); + } + + @Test public void lateCreatedGlobalAndOwnedOverrideAreBothMigrated() throws Exception { + Path config = registerBeforeImport("late-import"); + Path global = config.resolve("orespawn-worldgen.json"); + JsonObject before = quartzProfile(8), expected = before.deepCopy(); + expected.getAsJsonObject("ores").getAsJsonObject("minecraft:quartz_ore") + .addProperty("block", "minecraft:nether_quartz_ore"); + Files.writeString(global, before.toString()); + Path override = config.resolve("basepower-orespawn.json"); + JsonObject provider = JsonParser.parseString(""" + {"schema_version":4,"provider_modid":"basepower","provider_revision":3, + "ores":{"basepower:legacy/saved_oil":{"block":"poweradvantage:crude_oil", + "enabled":false,"dimensions":{"minecraft:overworld":{"enabled":false, + "frequency":0.625,"min_y":19,"max_y":47,"private":{"keep":7}}}}}, + "fluid_deposits":{},"user":{"setting":"poweradvantage:crude_oil"}} + """).getAsJsonObject(); + Files.writeString(override, provider.toString()); + assertTrue(OreSpawnProfileMigration.migrateLateProfiles(config)); + assertEquals(expected, read(global)); + JsonObject migrated = read(override), retained = provider.getAsJsonObject("ores") + .getAsJsonObject("basepower:legacy/saved_oil").deepCopy(); + retained.addProperty("block", "basepower:crude_oil"); + assertEquals(retained, migrated.getAsJsonObject("ores").get("basepower:legacy/saved_oil")); + assertEquals(4, migrated.get("schema_version").getAsInt()); + assertEquals(provider.get("provider_revision"), migrated.get("provider_revision")); + assertEquals(provider.get("user"), migrated.get("user")); + assertFalse(migrated.getAsJsonObject("fluid_deposits") + .getAsJsonObject("basepower:fluid_deposit/desert_crude_oil").get("enabled").getAsBoolean()); + } + + @Test public void lateRepairPreservesRootSevenEightAndEveryNonOutputValue() throws Exception { + for (int schema : new int[] {7, 8}) { + Path config = registerBeforeImport("schema-" + schema); + Path file = config.resolve("orespawn-worldgen.json"); + JsonObject source = quartzProfile(schema), expected = source.deepCopy(); + expected.getAsJsonObject("ores").getAsJsonObject("minecraft:quartz_ore") + .addProperty("block", "minecraft:nether_quartz_ore"); + Files.writeString(file, source.toString()); + assertTrue(OreSpawnProfileMigration.migrateLateProfiles(config)); + assertEquals(expected, read(file)); + } + } + + @Test public void absentAndAlreadyRepairedLateFilesAreIdempotentIncludingEvidence() throws Exception { + Path config = registerBeforeImport("idempotent"); + assertFalse(OreSpawnProfileMigration.migrateLateProfiles(config)); + try (var files = Files.list(config)) { assertEquals(0, files.count()); } + Path file = config.resolve("orespawn-worldgen.json"); + byte[] original = ("\n" + quartzProfile(8) + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8); + Files.write(file, original); + assertTrue(OreSpawnProfileMigration.migrateLateProfiles(config)); + Map first = new LinkedHashMap<>(); + try (var files = Files.list(config)) { + for (Path path : files.toList()) first.put(path, Files.readAllBytes(path)); + } + assertEquals(3, first.size()); + Path backup = first.keySet().stream().filter(path -> path.toString().endsWith(".bak")).findFirst().orElseThrow(); + assertArrayEquals(original, first.get(backup)); + assertFalse(OreSpawnProfileMigration.migrateLateProfiles(config)); + try (var files = Files.list(config)) { assertEquals(first.keySet(), new java.util.HashSet<>(files.toList())); } + for (var entry : first.entrySet()) assertArrayEquals(entry.getValue(), Files.readAllBytes(entry.getKey())); + } + + @Test public void lateActiveCollisionRefusesWithoutChangingTheImportedFile() throws Exception { + Path config = registerBeforeImport("refused"); + JsonObject source = quartzProfile(8); + source.addProperty("manage_vanilla_ores", true); + JsonObject current = source.getAsJsonObject("ores").getAsJsonObject("minecraft:quartz_ore").deepCopy(); + current.addProperty("block", "minecraft:nether_quartz_ore"); + source.getAsJsonObject("ores").add("minecraft:nether_quartz_ore", current); + Path file = config.resolve("orespawn-worldgen.json"); + Files.writeString(file, source.toString()); + byte[] before = Files.readAllBytes(file); + try { + OreSpawnProfileMigration.migrateLateProfiles(config); + fail("Ambiguous active imported rules must not be silently accepted"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("needs review")); + } + assertArrayEquals(before, Files.readAllBytes(file)); + } + + @Test public void loadCompleteEnqueuesRepairThenConditionallyRebakesAndRefreshes() throws Exception { + var power = NativeMigrationMixinContract.read("zone/moddev/mc/basepower/PowerAdvantage"); + var listener = NativeMigrationMixinContract.method(power, "finishOreSpawnMigration"); + var enqueues = NativeMigrationMixinContract.calls(listener).stream().filter(call -> + call.owner.equals("net/minecraftforge/fml/event/lifecycle/FMLLoadCompleteEvent") + && call.name.equals("enqueueWork") + && call.desc.equals("(Ljava/lang/Runnable;)Ljava/util/concurrent/CompletableFuture;")).toList(); + assertEquals("One actual load-complete work submission", 1, enqueues.size()); + var enqueueFrame = sources(power.name, listener)[listener.instructions.indexOf(enqueues.getFirst())]; + var runnable = enqueueFrame.getStack(enqueueFrame.getStackSize() - 1); + assertEquals("The submitted Runnable has one producer", 1, runnable.insns.size()); + assertTrue("The submitted Runnable is a lambda", runnable.insns.iterator().next() instanceof InvokeDynamicInsnNode); + var lambda = (InvokeDynamicInsnNode) runnable.insns.iterator().next(); + assertEquals("java/lang/invoke/LambdaMetafactory", lambda.bsm.getOwner()); + assertTrue(Set.of("metafactory", "altMetafactory").contains(lambda.bsm.getName())); + var implementations = Arrays.stream(lambda.bsmArgs).filter(Handle.class::isInstance).map(Handle.class::cast) + .filter(handle -> handle.getOwner().equals(power.name)).toList(); + assertEquals("One exact Power callback implementation", 1, implementations.size()); + var implementation = implementations.getFirst(); + var callback = NativeMigrationMixinContract.method(power, implementation.getName() + implementation.getDesc()); + var calls = NativeMigrationMixinContract.calls(callback); + var repairs = calls.stream().filter(call -> call.owner.equals("zone/moddev/mc/basepower/migration/OreSpawnProfileMigration") + && call.name.equals("migrateLateProfiles")).toList(); + assertEquals("One repair in the submitted callback", 1, repairs.size()); + var repair = repairs.getFirst(); + assertEquals("(Ljava/nio/file/Path;)Z", repair.desc); + var frames = sources(power.name, callback); + var gates = new ArrayList(); + for (var insn : callback.instructions) { + if (!(insn instanceof JumpInsnNode jump) || (jump.getOpcode() != Opcodes.IFEQ && jump.getOpcode() != Opcodes.IFNE)) continue; + var frame = frames[callback.instructions.indexOf(jump)]; + if (frame != null && frame.getStack(frame.getStackSize() - 1).insns.equals(Set.of(repair))) gates.add(jump); + } + assertEquals("One gate consumes the actual repair result", 1, gates.size()); + var gate = gates.getFirst(); + var changedCalls = normalCalls(gate.getOpcode() == Opcodes.IFEQ ? gate.getNext() : gate.label); + var unchangedCalls = normalCalls(gate.getOpcode() == Opcodes.IFEQ ? gate.label : gate.getNext()); + var expected = new ArrayList(); + int previous = callback.instructions.indexOf(repair); + for (String owner : List.of("GeomeConfig", "OreSpawnOreGeneration", "FluidDepositFeature")) { + String methodName = owner.equals("GeomeConfig") ? "bake" : "refreshWorldConfig"; + var matches = calls.stream().filter(value -> value.owner.equals("zone/moddev/mc/orespawn/worldgen/" + owner) + && value.name.equals(methodName)).toList(); + assertEquals("One " + owner + " refresh", 1, matches.size()); + var call = matches.getFirst(); + int index = callback.instructions.indexOf(call); + assertTrue(owner + " follows repair in the required order", index > previous); + expected.add(call); + previous = index; + } + assertEquals("Changed files execute all three refresh calls in order", expected, changedCalls); + assertTrue("Unchanged files execute no refresh calls", unchangedCalls.isEmpty()); + } + + private static Frame[] sources(String owner, MethodNode method) throws Exception { + return new Analyzer<>(new SourceInterpreter(Opcodes.ASM9) { + @Override public SourceValue copyOperation(AbstractInsnNode insn, SourceValue value) { return value; } + }).analyze(owner, method); + } + + private static List normalCalls(AbstractInsnNode start) { + var calls = new ArrayList(); + var visited = new HashSet(); + for (var insn = start; insn != null; insn = insn.getNext()) { + assertTrue("The callback's normal path terminates", visited.add(insn)); + if (insn instanceof MethodInsnNode call) calls.add(call); + if (insn.getOpcode() == Opcodes.RETURN) return calls; + if (insn instanceof JumpInsnNode jump) { + assertEquals("No unreviewed conditional inside the refresh path", Opcodes.GOTO, jump.getOpcode()); + insn = jump.label; + } + assertNotEquals("The normal path must not throw", Opcodes.ATHROW, insn.getOpcode()); + } + throw new AssertionError("The callback's normal path has no return"); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyEnchantmentContextTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyEnchantmentContextTest.java new file mode 100644 index 00000000..4460f409 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyEnchantmentContextTest.java @@ -0,0 +1,143 @@ +package zone.moddev.mc.basepower.migration; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.util.Map; +import net.minecraft.SharedConstants; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.resources.Identifier; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.util.datafix.DataFixers; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension; +import zone.moddev.mc.basemetals.migration.Legacy112WorldMigrator; + +import static org.junit.Assert.*; + +public class LegacyEnchantmentContextTest { + @BeforeClass public static void registerCompanion() throws Exception { + SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + Constructor constructor = BaseMetalsLegacyMigration.class.getDeclaredConstructor(); + constructor.setAccessible(true); + Legacy112MigrationExtension.register(Identifier.fromNamespaceAndPath("basepower", "test_enchantment_context"), + constructor.newInstance()); + } + + @Test public void baseMetalsFirstUsesSavedEnchantmentsForDroppedItemsAndCartContents() throws Exception { + Map map = savedIds(); + Map previous = Map.copyOf(map); + try { + for (int version : new int[] {512, 1343}) { + for (boolean cart : new boolean[] {false, true}) { + map.clear(); + CompoundTag snapshot = registrySnapshot(version), before = snapshot.copy(); + prepareWorld(snapshot); + assertEquals("The extension must not edit the saved registry snapshot", before, snapshot); + CompoundTag chunk = chunk(version, cart); + LegacyWorldDataHook.prepareLegacyChunk(chunk); + LegacyWorldDataHook.prepareLegacyEntities(chunk); + CompoundTag converted = DataFixTypes.CHUNK.update(DataFixers.getDataFixer(), chunk, + version, SharedConstants.getCurrentVersion().dataVersion().version()); + converted = LegacyWorldDataHook.finalizeLegacyChunk(converted); + CompoundTag entity = converted.getListOrEmpty("entities").getCompoundOrEmpty(0); + CompoundTag item = cart ? entity.getListOrEmpty("Items").getCompoundOrEmpty(0) + : entity.getCompoundOrEmpty("Item"); + CompoundTag enchantments = item.getCompoundOrEmpty("components") + .getCompoundOrEmpty("minecraft:enchantments"); + assertEquals("Saved Forge IDs must not become vanilla curses: " + item, 4, enchantments.size()); + for (int index = 0; index < 4; index++) { + assertEquals(item.toString(), index + 1, enchantments.getIntOr("example:musket_" + index, 0)); + } + } + } + } finally { + map.clear(); + map.putAll(previous); + } + } + + @Test public void selectingAnotherWorldCannotReuseThePreviousEnchantmentIndex() throws Exception { + Map map = savedIds(); + Map previous = Map.copyOf(map); + try { + prepareWorld(registrySnapshot(512)); + assertEquals("example:musket_0", map.get(10)); + CompoundTag modern = new CompoundTag(), data = new CompoundTag(); + data.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); + modern.put("Data", data); + prepareWorld(modern); + assertTrue("A world with no legacy index must clear the previous world's IDs", map.isEmpty()); + } finally { + map.clear(); + map.putAll(previous); + } + } + + private static void prepareWorld(CompoundTag snapshot) throws Exception { + Method method = Legacy112WorldMigrator.class.getDeclaredMethod("prepareWorld", Path.class, CompoundTag.class); + method.setAccessible(true); + method.invoke(null, Path.of("build", "enchantment-context-fixture", "level.dat"), snapshot); + } + + @SuppressWarnings("unchecked") private static Map savedIds() throws Exception { + Field field = LegacyWorldDataHook.class.getDeclaredField("LEGACY_ENCHANTMENTS"); + field.setAccessible(true); + return (Map) field.get(null); + } + + private static CompoundTag registrySnapshot(int version) { + CompoundTag root = new CompoundTag(), data = new CompoundTag(), fml = new CompoundTag(); + CompoundTag registries = new CompoundTag(), enchantments = new CompoundTag(); + ListTag entries = new ListTag(); + data.putInt("DataVersion", version); + for (int index = 0; index < 4; index++) { + CompoundTag entry = new CompoundTag(); + entry.putInt("V", 10 + index); + entry.putString("K", "example:musket_" + index); + entries.add(entry); + } + enchantments.put("ids", entries); + registries.put("minecraft:enchantments", enchantments); + fml.put("Registries", registries); + root.put("FML", fml); + root.put("Data", data); + return root; + } + + private static CompoundTag chunk(int version, boolean cart) { + CompoundTag root = new CompoundTag(), level = new CompoundTag(), entity = new CompoundTag(); + CompoundTag item = new CompoundTag(), tag = new CompoundTag(); + ListTag entities = new ListTag(), enchantments = new ListTag(); + for (int index = 0; index < 4; index++) { + CompoundTag enchantment = new CompoundTag(); + enchantment.putShort("id", (short) (10 + index)); + enchantment.putShort("lvl", (short) (index + 1)); + enchantments.add(enchantment); + } + tag.put("ench", enchantments); + item.putString("id", "minecraft:diamond_sword"); + item.putByte("Count", (byte) 1); + item.putShort("Damage", (short) 5); + item.put("tag", tag); + entity.putString("id", cart ? (version < 704 ? "MinecartChest" : "minecraft:chest_minecart") + : (version < 704 ? "Item" : "minecraft:item")); + if (cart) { + item.putByte("Slot", (byte) 0); + ListTag items = new ListTag(); + items.add(item); + entity.put("Items", items); + } else entity.put("Item", item); + entities.add(entity); + level.put("Entities", entities); + level.putInt("xPos", 0); + level.putInt("zPos", 0); + root.put("Level", level); + root.putInt("DataVersion", version); + return root; + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyMachineItemsTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyMachineItemsTest.java index f71087e9..bfbb89e9 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyMachineItemsTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyMachineItemsTest.java @@ -240,13 +240,15 @@ private static net.minecraft.core.HolderLookup.Provider registries() { assertEquals(before.getCompoundOrEmpty("tag"), actual.getCompoundOrEmpty("components").getCompoundOrEmpty("minecraft:custom_data")); } - @Test public void registeredForeignToolKeepsWearInDirectAndNestedMachineSlots() { + @Test public void registeredForeignToolKeepsWearInDirectAndNestedMachineSlots() throws Exception { String id = "advantageworks_unit:legacy_foreign_tool"; net.minecraftforge.registries.GameData.unfreezeData(); var tool = new net.minecraft.world.item.Item(new net.minecraft.world.item.Item.Properties().durability(250) .setId(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.ITEM, net.minecraft.resources.Identifier.parse(id)))); net.minecraftforge.registries.ForgeRegistries.ITEMS.register(net.minecraft.resources.Identifier.parse(id), tool); + bindNativeFixtureComponents(id); + assertEquals(Integer.valueOf(250), tool.components().get(net.minecraft.core.component.DataComponents.MAX_DAMAGE)); CompoundTag foreign = stack(id, 9, 1); CompoundTag payload = new CompoundTag(); payload.putInt("Fixture", 31); @@ -256,7 +258,7 @@ private static net.minecraft.core.HolderLookup.Provider registries() { var direct = zone.moddev.mc.basepower.util.ItemData.readStack(registries(), foreign); CompoundTag chest = convertedTile(carrier(foreign)).getListOrEmpty("Items").getCompoundOrEmpty(0); var carried = parseStack(chest) - .get(net.minecraft.core.component.DataComponents.CONTAINER).nonEmptyItems().iterator().next(); + .get(net.minecraft.core.component.DataComponents.CONTAINER).nonEmptyItems().iterator().next().create(); for (var loaded : List.of(direct, carried)) { assertSame(tool, loaded.getItem()); assertEquals(1, loaded.getCount()); @@ -274,6 +276,35 @@ private static ItemStack parseStack(CompoundTag saved) { saved).getOrThrow(); } + private static void bindNativeFixtureComponents(String id) throws Exception { + // Other unit tests may construct unregistered items. Bake only this item's + // real constructor initializer, without applying empty maps to other holders. + var nativeInitializers = net.minecraft.core.registries.BuiltInRegistries.DATA_COMPONENT_INITIALIZERS; + var field = nativeInitializers.getClass().getDeclaredField("initializers"); + field.setAccessible(true); + var isolated = new net.minecraft.core.component.DataComponentInitializers(); + int found = 0; + for (Object entry : (List) field.get(nativeInitializers)) { + var keyAccessor = entry.getClass().getDeclaredMethod("key"); + var initializerAccessor = entry.getClass().getDeclaredMethod("initializer"); + keyAccessor.setAccessible(true); + initializerAccessor.setAccessible(true); + var key = (net.minecraft.resources.ResourceKey) keyAccessor.invoke(entry); + if (!key.identifier().toString().equals(id)) continue; + @SuppressWarnings("unchecked") + var initializer = (net.minecraft.core.component.DataComponentInitializers.Initializer) + initializerAccessor.invoke(entry); + @SuppressWarnings("unchecked") + var itemKey = (net.minecraft.resources.ResourceKey) key; + isolated.add(itemKey, initializer); + found++; + } + assertEquals("Exactly one native constructor initializer is required", 1, found); + isolated.build(registries()).forEach(pending -> pending.forEach((holder, components) -> { + if (holder.key().identifier().toString().equals(id)) holder.bindComponents(components); + })); + } + @Test public void positiveNativeContainerConversionsDoNotEmitErrors() { try (ErrorCapture capture = new ErrorCapture()) { nativeNestedContainerItemsAreFixedExactlyOnce(); diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerTransformerTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerTransformerTest.java index bcb5eb1d..ba45ebed 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerTransformerTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerTransformerTest.java @@ -34,7 +34,7 @@ import static org.junit.Assert.*; import static zone.moddev.mc.basepower.migration.NativeMigrationMixinContract.*; -/** Native redirect contracts and real handler execution; packaged tests prove live Mixin application. */ +/** Native redirect/callback contracts and real player DFU; packaged tests prove live Mixin application. */ public class LegacyPlayerTransformerTest { @Rule public final TemporaryFolder temp = new TemporaryFolder(); @@ -42,34 +42,46 @@ private static Binding loose() throws Exception { return binding("basepower", "PlayerDataStorageMixin", "preservePlayer"); } - private static Binding embedded() throws Exception { - return binding("basepower", "LevelStorageSourceMixin", "preserveEmbeddedPlayer"); + private static PlayerBoundary embedded() throws Exception { + return new PlayerBoundary(read("zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator"), + "L" + FIX_TYPES + ";updateToCurrentVersion(" + FIXER + TAG + "I)" + TAG); } - private static List targets() throws Exception { return List.of(loose(), embedded()); } + private static List targets() throws Exception { + Binding loose = loose(); + return List.of(new PlayerBoundary(loose.target(), loose.invocation()), embedded()); + } - @Test public void exactNativeLoosePlayerLoadAndEmbeddedUpdateAreRedirected() throws Exception { - for (Binding target : targets()) { + @Test public void nativeLooseRedirectAndSharedEmbeddedCallbackBindExactTargets() throws Exception { + for (Binding target : List.of(loose())) { assertEquals("*", target.selector()); assertTrue(target.injector().desc.endsWith("/Redirect;")); target.assertAt("INVOKE"); - String payload = target.target().name.endsWith("PlayerDataStorage") ? TAG : DYNAMIC; + String payload = TAG; assertEquals("L" + FIX_TYPES + ";updateToCurrentVersion(" + FIXER + payload + "I)" + payload, target.invocation()); assertEquals("(L" + FIX_TYPES + ";" + FIXER + payload + "I)" + payload, target.handler().desc); target.assertOnePlayerSite(target.target()); - String wrapper = payload.equals(TAG) ? "updateNativePlayer" : "updateNativeEmbeddedPlayer"; + String wrapper = "updateNativePlayer"; assertEquals(1, calls(target.handler()).stream().filter(call -> call.owner.equals( "zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes") && call.name.equals(wrapper)).count()); } assertEquals(1, value(loose().injector(), "require", -1)); assertEquals(1, value(loose().injector(), "allow", -1)); - // Other embedded data-fix types are deliberately redirected too, then forwarded unchanged. - assertEquals(4, embedded().invokeSites(embedded().target())); assertEquals(1, loose().invokeSites(loose().target())); + PlayerBoundary shared = embedded(); + MethodNode flatten = shared.assertOnePlayerSite(shared.target()); + assertEquals("flattenEmbeddedPlayer", flatten.name); + assertEquals("(" + TAG + "ILjava/util/List;)" + TAG, flatten.desc); + var callbacks = calls(flatten).stream().filter(call -> call.owner.equals( + "zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension") + && call.name.equals("afterPlayerFlattening") && call.desc.equals("(" + TAG + TAG + ")V")).toList(); + assertEquals(1, callbacks.size()); + assertTrue("The companion callback must precede componentization", flatten.instructions.indexOf(callbacks.getFirst()) + < flatten.instructions.indexOf(playerCall(shared, shared.target()))); } - @Test public void nativeEmbeddedRedirectExecutesAndDoesNotReconvertCurrentPlayer() throws Exception { + @Test public void nativeLooseRedirectExecutesAndDoesNotReconvertCurrentPlayer() throws Exception { SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); RecordingFixer fixer = new RecordingFixer(DataFixers.getDataFixer()); @@ -97,14 +109,56 @@ private static Binding embedded() throws Exception { CompoundTag unrelated = new CompoundTag(); unrelated.putString("witness", "native-forwarding"); fixer.playerCalls.clear(); - CompoundTag expected = DataFixTypes.LEVEL.updateToCurrentVersion(DataFixers.getDataFixer(), - unrelated.copy(), current); - assertEquals(expected, invoke(redirect, DataFixTypes.LEVEL, fixer, unrelated, current)); + var rejected = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> invoke(redirect, DataFixTypes.LEVEL, fixer, unrelated, current)); + assertTrue(rejected.getCause() instanceof IllegalArgumentException); + assertEquals("native-forwarding", unrelated.getStringOr("witness", "")); assertTrue("Unrelated types cannot enter the player wrapper", fixer.playerCalls.isEmpty()); } - @Test public void renamedNativeLambdaNamesDoNotAffectWildcardMatching() throws Exception { - for (Binding target : targets()) { + @Test public void sharedEmbeddedExtensionExecutesInBothOrdersWithoutRewritingTheSourceLevel() throws Exception { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + Class owner = Class.forName("zone.moddev.mc.basemetals.migration.Legacy112WorldMigrator"); + Method flatten = owner.getDeclaredMethod("flattenEmbeddedPlayer", CompoundTag.class, int.class, List.class); + flatten.setAccessible(true); + var constructor = BaseMetalsLegacyMigration.class.getDeclaredConstructor(); + constructor.setAccessible(true); + var power = constructor.newInstance(); + for (int old : new int[] {512, 1343}) for (boolean reverse : new boolean[] {false, true}) { + Path file = temp.newFolder("shared-" + old + "-" + reverse).toPath().resolve("level.dat"); + CompoundTag original = levelData(old, legacyPlayer()); + NbtIo.writeCompressed(original, file); + CompoundTag source = original.getCompoundOrEmpty("Data").getCompoundOrEmpty("Player").copy(); + power.prepareData(source, old, zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension.Payload.PLAYER); + CompoundTag prepared = source.copy(); + List calls = new ArrayList<>(); + var observer = new zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension() { + @Override public void afterPlayerFlattening(CompoundTag before, CompoundTag result) { + assertEquals(prepared, before); + assertEquals(1, result.getListOrEmpty("Inventory").getCompoundOrEmpty(0).getByteOr("Count", (byte) 0)); + assertFalse(result.getListOrEmpty("Inventory").getCompoundOrEmpty(0).contains("components")); + calls.add("before-components"); + } + }; + var extensions = reverse ? List.of(observer, power) : List.of(power, observer); + CompoundTag migrated = (CompoundTag) flatten.invoke(null, source.copy(), old, extensions); + assertEquals(prepared, source); + assertEquals(List.of("before-components"), calls); + assertPlayerContents(migrated); + int current = SharedConstants.getCurrentVersion().dataVersion().version(); + assertEquals(4790, current); + assertEquals(current, migrated.getIntOr("DataVersion", 0)); + assertEquals(original, NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap())); + calls.clear(); + assertEquals(migrated, flatten.invoke(null, migrated.copy(), current, extensions)); + assertTrue("Current host player cannot re-enter the shared legacy callback", calls.isEmpty()); + assertEquals(original, NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap())); + } + } + + @Test public void renamedNativePlayerMethodNamesDoNotAffectBoundaryMatching() throws Exception { + for (PlayerBoundary target : targets()) { ClassNode nativeClass = read(target.target().name); target.assertOnePlayerSite(nativeClass).name = "renamedPlayerLambda"; assertEquals("renamedPlayerLambda", target.assertOnePlayerSite(nativeClass).name); @@ -112,7 +166,7 @@ private static Binding embedded() throws Exception { } @Test public void absentCallFailsClosedWithoutAcceptingOtherOwnersOrNames() throws Exception { - for (Binding target : targets()) for (boolean owner : new boolean[] {true, false}) { + for (PlayerBoundary target : targets()) for (boolean owner : new boolean[] {true, false}) { ClassNode nativeClass = read(target.target().name); MethodInsnNode call = playerCall(target, nativeClass); if (owner) call.owner = "other/UnrelatedNBTUtil"; @@ -122,7 +176,7 @@ private static Binding embedded() throws Exception { } @Test public void duplicateCallsInOneReaderFailClosed() throws Exception { - for (Binding target : targets()) { + for (PlayerBoundary target : targets()) { ClassNode nativeClass = read(target.target().name); MethodNode method = target.assertOnePlayerSite(nativeClass); MethodNode copy = copy(method, method.name); @@ -136,14 +190,14 @@ private static Binding embedded() throws Exception { } @Test public void duplicateEmbeddedPlayerReadersFailClosed() throws Exception { - Binding target = embedded(); + PlayerBoundary target = embedded(); ClassNode nativeClass = read(target.target().name); nativeClass.methods.add(copy(target.assertOnePlayerSite(nativeClass), "anotherPlayerReader")); rejectedUnchanged(target, nativeClass); } @Test public void wrongOverloadIsNotMistakenForTheThreeArgumentCall() throws Exception { - for (Binding target : targets()) { + for (PlayerBoundary target : targets()) { ClassNode nativeClass = read(target.target().name); MethodInsnNode call = playerCall(target, nativeClass); call.desc = call.desc.replace(";I)", ";II)"); @@ -152,11 +206,11 @@ private static Binding embedded() throws Exception { } @Test public void embeddedReceiverCannotBorrowANearbyPlayerConstant() throws Exception { - Binding target = embedded(); + PlayerBoundary target = embedded(); ClassNode nativeClass = read(target.target().name); MethodNode method = target.assertOnePlayerSite(nativeClass); - var player = Arrays.stream(method.instructions.toArray()).filter(insn -> insn instanceof FieldInsnNode f - && f.owner.equals(FIX_TYPES) && f.name.equals("PLAYER")).findFirst().orElseThrow(); + var player = playerReceiver(nativeClass, method, playerCall(target, nativeClass)); + assertNotNull(player); InsnList change = new InsnList(); change.add(new InsnNode(Opcodes.POP)); change.add(new FieldInsnNode(Opcodes.GETSTATIC, FIX_TYPES, "LEVEL", "L" + FIX_TYPES + ";")); @@ -164,44 +218,37 @@ private static Binding embedded() throws Exception { rejectedUnchanged(target, nativeClass); } - @Test public void publicEmbeddedReadStillDelegatesToTheRedirectedNativeReader() throws Exception { - Binding target = embedded(); - ClassNode access = read(STORAGE + "LevelStorageSource$LevelStorageAccess"); - assertEquals(1, calls(access).stream().filter(call -> call.owner.equals(target.target().name) - && call.name.equals("readLevelDataTagFixed") - && call.desc.equals("(Ljava/nio/file/Path;" + FIXER + ")" + DYNAMIC)).count()); - MethodNode nativeReader = method(target.target(), "readLevelDataTagFixed"); - var updates = calls(nativeReader).stream().filter(call -> call.owner.equals("com/mojang/serialization/Dynamic") - && call.name.equals("update") && call.desc.equals( - "(Ljava/lang/String;Ljava/util/function/Function;)Lcom/mojang/serialization/Dynamic;")).toList(); - assertEquals("Player and WorldGenSettings are separate native updates", 2, updates.size()); - var frames = new org.objectweb.asm.tree.analysis.Analyzer<>( - new org.objectweb.asm.tree.analysis.SourceInterpreter()).analyze(target.target().name, nativeReader); - var keys = new java.util.HashSet(); - for (var call : updates) { - var frame = frames[nativeReader.instructions.indexOf(call)]; - var key = frame.getStack(frame.getStackSize() - 2); - assertEquals(1, key.insns.size()); - String name = (String) ((org.objectweb.asm.tree.LdcInsnNode) key.insns.iterator().next()).cst; - keys.add(name); - if (!name.equals("Player")) continue; - var function = frame.getStack(frame.getStackSize() - 1); - assertEquals(1, function.insns.size()); - var lambda = (org.objectweb.asm.tree.InvokeDynamicInsnNode) function.insns.iterator().next(); - MethodNode reader = target.assertOnePlayerSite(target.target()); - assertTrue("The Player key must use the exact PLAYER lambda", Arrays.stream(lambda.bsmArgs) - .anyMatch(arg -> arg instanceof org.objectweb.asm.Handle handle - && handle.getOwner().equals(target.target().name) && handle.getName().equals(reader.name) - && handle.getDesc().equals(reader.desc))); - } - assertEquals(Set.of("Player", "WorldGenSettings"), keys); - assertTrue(target.assertOnePlayerSite(target.target()).name.startsWith("lambda$readLevelDataTagFixed$")); + @Test public void selectedWorldHostSeedDelegatesToSharedFlatteningBeforeFileFixing() throws Exception { + Binding capture = binding("basemetals", "LevelStorageSourceMixin", "prepareWorld"); + assertSelectedWorld(capture); + ClassNode legacyStorage = read(STORAGE + "LevelStorageSource"); + assertFalse(legacyStorage.methods.stream().anyMatch(method -> method.name.equals("readLevelDataTagFixed"))); + ClassNode powerStorage = read("zone/moddev/mc/basepower/mixin/LevelStorageSourceMixin"); + assertFalse("Removed embedded redirect must not survive as an unbound handler", powerStorage.methods.stream() + .anyMatch(method -> method.name.equals("basepower$preserveEmbeddedPlayer"))); + String hooks = "zone/moddev/mc/basemetals/migration/LegacyWorldDataHook"; + String migrator = embedded().target().name; + MethodNode prepare = method(read(hooks), "prepareSelectedWorld"); + var seedCall = calls(prepare).stream().filter(call -> call.owner.equals(hooks) + && call.name.equals("preserveEmbeddedPlayer") && call.desc.equals("(Ljava/nio/file/Path;)V")).findFirst().orElseThrow(); + var migrateCall = calls(prepare).stream().filter(call -> call.owner.equals(migrator) + && call.name.equals("migrateIfNeeded") && call.desc.equals("(Ljava/nio/file/Path;)V")).findFirst().orElseThrow(); + assertTrue("Legacy preparation precedes host componentization", prepare.instructions.indexOf(migrateCall) + < prepare.instructions.indexOf(seedCall)); + MethodNode seed = method(read(hooks), "preserveEmbeddedPlayer"); + assertEquals(1, calls(seed).stream().filter(call -> call.owner.equals(migrator) + && call.name.equals("flattenEmbeddedPlayer") && call.desc.equals("(" + TAG + "I)" + TAG)).count()); + assertEquals("Both old and relocated loose-player paths have authority", 2, calls(seed).stream() + .filter(call -> call.owner.equals("java/nio/file/Files") && call.name.equals("exists")).count()); + ClassNode companion = read("zone/moddev/mc/basepower/migration/BaseMetalsLegacyMigration"); + assertEquals(1, calls(method(companion, "afterPlayerFlattening")).stream().filter(call -> call.owner.equals( + "zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes") && call.name.equals("preserveNames") + && call.desc.equals("(Lnet/minecraft/nbt/Tag;Lnet/minecraft/nbt/Tag;)V")).count()); } private static Method redirect() throws Exception { - Class mixin = Class.forName("zone.moddev.mc.basepower.mixin.LevelStorageSourceMixin"); - Method method = mixin.getDeclaredMethod("basepower$preserveEmbeddedPlayer", - DataFixTypes.class, DataFixer.class, Dynamic.class, int.class); + Method method = LegacyPlayerDataFixes.class.getDeclaredMethod("updateNativePlayer", + DataFixTypes.class, DataFixer.class, CompoundTag.class, int.class); method.setAccessible(true); return method; } @@ -209,12 +256,12 @@ private static Method redirect() throws Exception { private static CompoundTag invoke(Method redirect, DataFixTypes type, DataFixer fixer, CompoundTag input, int version) throws Exception { CompoundTag before = input.copy(); - Dynamic result = (Dynamic) redirect.invoke(null, type, fixer, new Dynamic<>(NbtOps.INSTANCE, input), version); + CompoundTag result = (CompoundTag) redirect.invoke(null, type, fixer, input, version); assertEquals("Native boundary cannot mutate its caller", before, input); - return (CompoundTag) result.getValue(); + return result; } - private static MethodInsnNode playerCall(Binding binding, ClassNode nativeClass) throws Exception { + private static MethodInsnNode playerCall(PlayerBoundary binding, ClassNode nativeClass) throws Exception { return calls(binding.assertOnePlayerSite(nativeClass)).stream().filter(binding::matches).findFirst().orElseThrow(); } @@ -224,7 +271,7 @@ private static MethodNode copy(MethodNode source, String name) { return result; } - private static void rejectedUnchanged(Binding target, ClassNode nativeClass) throws Exception { + private static void rejectedUnchanged(PlayerBoundary target, ClassNode nativeClass) throws Exception { byte[] before = bytes(nativeClass); assertThrows("Unsupported native PLAYER ABI must fail the contract", AssertionError.class, () -> target.assertOnePlayerSite(nativeClass)); diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyRegistryArchiveTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyRegistryArchiveTest.java index 3ab15ce1..2e137879 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyRegistryArchiveTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyRegistryArchiveTest.java @@ -61,6 +61,43 @@ public class LegacyRegistryArchiveTest { } } + @Test public void relocatedAndCustomDimensionChunksRetainExactTerrainGuards() throws Exception { + Path world = temporary.newFolder().toPath(); + writeChunkIndex(world, "dimensions/minecraft/overworld/region/r.-1.0.mca", 31, 0); + writeChunkIndex(world, "dimensions/minecraft/the_nether/region/r.0.-1.mca", 0, 31); + writeChunkIndex(world, "dimensions/other/deep/cavern/region/r.1.-1.mca", 2, 3); + // A retained old directory may overlap a native directory during recovery. + writeChunkIndex(world, "region/r.-1.0.mca", 31, 0); + try { + assertEquals("Duplicate paths must not invent an extra saved chunk", 3, + LegacyWorldDataHook.indexLegacyChunks(world.toFile())); + assertTrue(LegacyWorldDataHook.isProtectedChunk(Identifier.parse("minecraft:overworld"), + new BlockPos(-16, 64, 0))); + assertTrue(LegacyWorldDataHook.isProtectedChunk(Identifier.parse("minecraft:the_nether"), + new BlockPos(0, 64, -16))); + assertTrue(LegacyWorldDataHook.isProtectedChunk(Identifier.parse("other:deep/cavern"), + new BlockPos(34 * 16, 64, -29 * 16))); + assertFalse(LegacyWorldDataHook.isProtectedChunk(Identifier.parse("minecraft:the_end"), + new BlockPos(-16, 64, 0))); + assertFalse(LegacyWorldDataHook.isProtectedChunk(Identifier.parse("other:deep/cavern"), + new BlockPos(35 * 16, 64, -29 * 16))); + assertEquals("Repeated indexing must remain idempotent", 3, + LegacyWorldDataHook.indexLegacyChunks(world.toFile())); + } finally { + LegacyWorldDataHook.indexLegacyChunks(temporary.newFolder()); + } + } + + private static void writeChunkIndex(Path world, String relative, int localX, int localZ) throws Exception { + Path region = world.resolve(relative); + Files.createDirectories(region.getParent()); + byte[] header = new byte[4096]; + int offset = (localX + localZ * 32) * 4; + header[offset + 2] = 2; + header[offset + 3] = 1; + Files.write(region, header); + } + private static CompoundTag registrySnapshot() { CompoundTag source = new CompoundTag(); CompoundTag fml = new CompoundTag(); diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyTransformerOrderTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyTransformerOrderTest.java index 5decf0b4..8f88b829 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyTransformerOrderTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyTransformerOrderTest.java @@ -44,6 +44,27 @@ public class LegacyTransformerOrderTest { fresh.at().values.set(fresh.at().values.indexOf("value") + 1, "HEAD"); assertThrows("Inventory staging at HEAD would race the other mod", AssertionError.class, () -> fresh.assertAt("INVOKE")); + Binding overload = binding("basepower", "SimpleRegionStorageMixin", "prepareChunk"); + overload.nativeMethod().desc = "(" + TAG + "I" + TAG + ")" + TAG; + assertThrows("Forge61's three-argument overload cannot satisfy Forge64", AssertionError.class, overload::nativeMethod); + } + + @Test public void selectedWorldCaptureRejectsNativeLockReadAndFallbackDrift() throws Exception { + for (String mod : List.of("basepower", "basemetals")) { + String mixin = mod.equals("basepower") ? "SelectedWorldStorageMixin" : "LevelStorageSourceMixin"; + Binding read = binding(mod, mixin, "prepareWorld"); + assertSelectedWorld(read); + calls(read.nativeMethod()).stream().filter(read::matches).findFirst().orElseThrow().name = "renamedRawRead"; + assertThrows(AssertionError.class, () -> assertSelectedWorld(read)); + Binding lock = binding(mod, mixin, "prepareWorld"); + calls(lock.nativeMethod()).stream().filter(call -> call.name.equals("checkLock")) + .findFirst().orElseThrow().name = "unrelatedLock"; + assertThrows(AssertionError.class, () -> assertSelectedWorld(lock)); + Binding fallback = binding(mod, mixin, "prepareWorld"); + Arrays.stream(fallback.handler().instructions.toArray()).filter(insn -> insn instanceof JumpInsnNode + && insn.getOpcode() == Opcodes.IFNE).map(JumpInsnNode.class::cast).findFirst().orElseThrow().setOpcode(Opcodes.IFEQ); + assertThrows("Fallback preparation must fail closed", AssertionError.class, () -> assertSelectedWorld(fallback)); + } } @Test public void separateEntityStorageStillUsesTheNativeRegionConverterThroughThePowerHook() throws Exception { @@ -71,12 +92,17 @@ public class LegacyTransformerOrderTest { config = JsonParser.parseReader(reader).getAsJsonObject(); } assertTrue(config.get("required").getAsBoolean()); - assertEquals("JAVA_21", config.get("compatibilityLevel").getAsString()); + String compatibility = config.get("compatibilityLevel").getAsString(); + assertEquals("JAVA_21", compatibility); + assertEquals(org.spongepowered.asm.mixin.MixinEnvironment.CompatibilityLevel.JAVA_21, + org.spongepowered.asm.mixin.MixinEnvironment.CompatibilityLevel.valueOf(compatibility)); + assertEquals("Mixin compatibility does not lower Java25 production bytecode", 69, + read("zone/moddev/mc/" + mod + "/" + (mod.equals("basepower") ? "PowerAdvantage" : "BaseMetals")).version & 0xffff); assertEquals(1, config.getAsJsonObject("injectors").get("defaultRequire").getAsInt()); assertTrue(config.getAsJsonArray("mixins").asList().stream().map(value -> value.getAsString()) .toList().containsAll(List.of("BlockStateDataMixin", "LevelStorageSourceMixin", "SimpleRegionStorageMixin", "WorldGenRegionMixin"))); - if (mod.equals("basepower")) assertEquals(Set.of("BlockStateDataMixin", "LevelStorageSourceMixin", + if (mod.equals("basepower")) assertEquals(Set.of("BlockStateDataMixin", "LevelStorageSourceMixin", "SelectedWorldStorageMixin", "PlayerDataStorageMixin", "SimpleRegionStorageMixin", "WorldGenRegionMixin"), config.getAsJsonArray("mixins").asList().stream().map(value -> value.getAsString()) .collect(java.util.stream.Collectors.toSet())); @@ -108,18 +134,13 @@ public class LegacyTransformerOrderTest { private static void check(List mods) throws Exception { for (String mod : mods) { String hook = mod.equals("basepower") ? POWER : METALS; - Binding world = binding(mod, "LevelStorageSourceMixin", "prepareWorld"); - world.assertAt("HEAD"); - assertEquals(STORAGE + "LevelStorageSource", world.target().name); - assertEquals("readLevelDataTagFixed", world.selector()); - assertFalse(world.cancellable()); - assertCalls(world.handler(), hook, "prepareLegacyWorld", "(Ljava/io/File;)V"); - assertTrue("Only primary level.dat may initialize a migration snapshot", - Arrays.stream(world.handler().instructions.toArray()).anyMatch(insn -> - insn instanceof LdcInsnNode constant && "level.dat".equals(constant.cst))); - var prepare = calls(world.handler()).stream().filter(call -> call.name.equals("prepareLegacyWorld")).findFirst().orElseThrow(); - assertTrue("Primary-file guard must precede snapshot preparation", Arrays.stream(world.handler().instructions.toArray()) - .takeWhile(insn -> insn != prepare).anyMatch(JumpInsnNode.class::isInstance)); + Binding world = binding(mod, mod.equals("basepower") ? "SelectedWorldStorageMixin" : "LevelStorageSourceMixin", "prepareWorld"); + assertSelectedWorld(world); + assertCalls(world.handler(), hook, mod.equals("basepower") ? "prepareLegacyWorld" : "prepareSelectedWorld", + mod.equals("basepower") ? "(Ljava/io/File;)V" : "(Ljava/nio/file/Path;)V"); + assertTrue("Capture must select the primary level-data resource", Arrays.stream(world.handler().instructions.toArray()) + .anyMatch(insn -> insn instanceof org.objectweb.asm.tree.FieldInsnNode field + && field.owner.equals(STORAGE + "LevelResource") && field.name.equals("LEVEL_DATA_FILE"))); Binding before = binding(mod, "SimpleRegionStorageMixin", "prepareChunk"); before.assertAt("HEAD"); diff --git a/src/test/java/zone/moddev/mc/basepower/migration/Native12111BiomeRegistryMappingTest.java b/src/test/java/zone/moddev/mc/basepower/migration/Native12111BiomeRegistryMappingTest.java index f9d15c6f..6b3ae515 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/Native12111BiomeRegistryMappingTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/Native12111BiomeRegistryMappingTest.java @@ -109,7 +109,8 @@ private static Set nativeBiomeKeys() throws Exception { private static String fixName(String name) { var original = new Dynamic<>(NbtOps.INSTANCE, StringTag.valueOf(name)); - var fixed = DataFixers.getDataFixer().update(References.BIOME, original, 1631, 4671); + var fixed = DataFixers.getDataFixer().update(References.BIOME, original, 1631, + SharedConstants.getCurrentVersion().dataVersion().version()); assertEquals("DFU must not mutate the original saved name", name, original.asString().result().orElseThrow()); return fixed.asString().result().orElseThrow(); } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/Native12111RegistryMigrationTest.java b/src/test/java/zone/moddev/mc/basepower/migration/Native12111RegistryMigrationTest.java index 3ebc0b4c..eb4860a0 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/Native12111RegistryMigrationTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/Native12111RegistryMigrationTest.java @@ -35,11 +35,11 @@ public class Native12111RegistryMigrationTest { properties.putString("waterlogged", "true"); block.put("Properties", properties); var fixed = DataFixers.getDataFixer().update(References.BLOCK_STATE, - new Dynamic<>(NbtOps.INSTANCE, block), 3839, 4671); + new Dynamic<>(NbtOps.INSTANCE, block), 3839, currentVersion()); assertEquals("minecraft:iron_chain", fixed.get("Name").asString().result().orElseThrow()); assertEquals(new Dynamic<>(NbtOps.INSTANCE, properties), fixed.get("Properties").result().orElseThrow()); var stack = DataFixers.getDataFixer().update(References.ITEM_STACK, - new Dynamic<>(NbtOps.INSTANCE, item("chain", 7)), 3839, 4671); + new Dynamic<>(NbtOps.INSTANCE, item("chain", 7)), 3839, currentVersion()); assertEquals("minecraft:iron_chain", stack.get("id").asString().result().orElseThrow()); assertEquals(7, stack.get("count").asInt(-1)); assertNull(VanillaRegistryRemaps.replacement(id("entity_type"), id("chain"))); @@ -111,12 +111,16 @@ private static CompoundTag witness(String name) { private static void assertFixedEntity(CompoundTag entity, String target) { var fixed = DataFixers.getDataFixer().update(References.ENTITY, - new Dynamic<>(NbtOps.INSTANCE, entity), 3839, 4671); + new Dynamic<>(NbtOps.INSTANCE, entity), 3839, currentVersion()); assertEquals(id(target).toString(), fixed.get("id").asString().result().orElseThrow()); assertEquals(121111, fixed.get("AdvWorksOpaqueWitness").asInt(-1)); assertTrue(ForgeRegistries.ENTITY_TYPES.containsKey(id(target))); } + private static int currentVersion() { + return SharedConstants.getCurrentVersion().dataVersion().version(); + } + @SuppressWarnings("unchecked") private static void assertAction(String registry, String name, MissingMappingsEvent.Action expected) { var key = ResourceKey.createRegistryKey(id(registry)); diff --git a/src/test/java/zone/moddev/mc/basepower/migration/NativeMigrationMixinContract.java b/src/test/java/zone/moddev/mc/basepower/migration/NativeMigrationMixinContract.java index ca732b4e..2ef84c3c 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/NativeMigrationMixinContract.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/NativeMigrationMixinContract.java @@ -14,6 +14,8 @@ import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.VarInsnNode; import org.objectweb.asm.tree.analysis.Analyzer; import org.objectweb.asm.tree.analysis.SourceInterpreter; import org.objectweb.asm.tree.analysis.SourceValue; @@ -27,7 +29,7 @@ final class NativeMigrationMixinContract { static final String TAG = "Lnet/minecraft/nbt/CompoundTag;"; static final String DYNAMIC = "Lcom/mojang/serialization/Dynamic;"; static final String FIXER = "Lcom/mojang/datafixers/DataFixer;"; - static final String UPGRADE = "upgradeChunkTag(" + TAG + "I" + TAG + ")" + TAG; + static final String UPGRADE = "upgradeChunkTag(" + TAG + "I" + TAG + "I)" + TAG; private NativeMigrationMixinContract() {} @@ -98,6 +100,75 @@ static Binding binding(String mod, String mixin, String handler) throws IOExcept return new Binding(owner, callback, injector, at, read(targets.getFirst().getInternalName()), selectors.getFirst()); } + static List playerMethods(ClassNode nativeClass, String invocation) throws Exception { + List result = new ArrayList<>(); + for (MethodNode method : nativeClass.methods) { + for (MethodInsnNode call : calls(method)) { + if (invocation.equals("L" + call.owner + ";" + call.name + call.desc) + && playerReceiver(nativeClass, method, call) != null) result.add(method); + } + } + return result; + } + + static FieldInsnNode playerReceiver(ClassNode owner, MethodNode method, MethodInsnNode call) throws Exception { + var analyzer = new Analyzer(new SourceInterpreter(Opcodes.ASM9) { + @Override public SourceValue copyOperation(AbstractInsnNode insn, SourceValue input) { return input; } + }); + var frame = analyzer.analyze(owner.name, method)[method.instructions.indexOf(call)]; + var receiver = frame.getStack(frame.getStackSize() - Type.getArgumentTypes(call.desc).length - 1); + if (receiver.insns.size() == 1 && receiver.insns.iterator().next() instanceof FieldInsnNode field + && field.getOpcode() == Opcodes.GETSTATIC && field.owner.equals(FIX_TYPES) + && field.name.equals("PLAYER")) return field; + return null; + } + + record PlayerBoundary(ClassNode target, String invocation) { + boolean matches(MethodInsnNode call) { + return invocation.equals("L" + call.owner + ";" + call.name + call.desc); + } + List playerMethods(ClassNode owner) throws Exception { + return NativeMigrationMixinContract.playerMethods(owner, invocation); + } + MethodNode assertOnePlayerSite(ClassNode owner) throws Exception { + List methods = playerMethods(owner); + assertEquals("Exactly one PLAYER componentization boundary: " + owner.name, 1, methods.size()); + return methods.getFirst(); + } + } + + static void assertSelectedWorld(Binding world) throws Exception { + world.assertAt("INVOKE"); + assertEquals(STORAGE + "LevelStorageSource$LevelStorageAccess", world.target.name); + assertEquals("getUnfixedDataTag", world.selector); + assertEquals("(Z)" + DYNAMIC, world.nativeMethod().desc); + assertFalse(world.cancellable()); + assertEquals("L" + STORAGE + "LevelStorageSource;readLevelDataTagRaw(Ljava/nio/file/Path;)" + TAG, + world.invocation()); + var nativeMethod = world.nativeMethod(); + var reads = calls(nativeMethod).stream().filter(world::matches).toList(); + var locks = calls(nativeMethod).stream().filter(call -> call.owner.equals(world.target.name) + && call.name.equals("checkLock") && call.desc.equals("()V")).toList(); + assertEquals("Exactly one selected raw read", 1, reads.size()); + assertEquals("Exactly one native session-lock check", 1, locks.size()); + assertTrue("Capture must follow native session locking", nativeMethod.instructions.indexOf(locks.getFirst()) + < nativeMethod.instructions.indexOf(reads.getFirst())); + var prepares = calls(world.handler).stream().filter(call -> call.name.equals("prepareLegacyWorld") + || call.name.equals("prepareSelectedWorld")).toList(); + assertEquals(1, prepares.size()); + int preparation = world.handler.instructions.indexOf(prepares.getFirst()); + var frames = new Analyzer<>(new SourceInterpreter()).analyze(world.mixin.name, world.handler); + boolean guarded = false; + for (int i = 0; i < preparation; i++) { + if (!(world.handler.instructions.get(i) instanceof JumpInsnNode jump) + || jump.getOpcode() != Opcodes.IFNE || world.handler.instructions.indexOf(jump.label) <= preparation) continue; + var condition = frames[i].getStack(frames[i].getStackSize() - 1); + guarded |= condition.insns.size() == 1 && condition.insns.iterator().next() instanceof VarInsnNode load + && load.getOpcode() == Opcodes.ILOAD && load.var == 1; + } + assertTrue("Only !useFallback may prepare the selected world", guarded); + } + record Binding(ClassNode mixin, MethodNode handler, AnnotationNode injector, AnnotationNode at, ClassNode target, String selector) { String point() { return (String) value(at, "value", ""); } @@ -135,24 +206,7 @@ boolean matches(MethodInsnNode call) { } List playerMethods(ClassNode nativeClass) throws Exception { - List result = new ArrayList<>(); - for (MethodNode method : nativeClass.methods) { - if (calls(method).stream().noneMatch(this::matches)) continue; - // Track the actual receiver through locals; a nearby PLAYER constant is not sufficient. - var analyzer = new Analyzer(new SourceInterpreter(Opcodes.ASM9) { - @Override public SourceValue copyOperation(AbstractInsnNode insn, SourceValue input) { return input; } - }); - var frames = analyzer.analyze(nativeClass.name, method); - for (int index = 0; index < method.instructions.size(); index++) { - if (!(method.instructions.get(index) instanceof MethodInsnNode call) || !matches(call)) continue; - var frame = frames[index]; - var receiver = frame.getStack(frame.getStackSize() - Type.getArgumentTypes(call.desc).length - 1); - if (receiver.insns.size() == 1 && receiver.insns.iterator().next() instanceof FieldInsnNode field - && field.getOpcode() == Opcodes.GETSTATIC && field.owner.equals(FIX_TYPES) - && field.name.equals("PLAYER")) result.add(method); - } - } - return result; + return NativeMigrationMixinContract.playerMethods(nativeClass, invocation()); } MethodNode assertOnePlayerSite(ClassNode nativeClass) throws Exception { diff --git a/src/test/java/zone/moddev/mc/basepower/migration/OreSpawnProfileMigrationTest.java b/src/test/java/zone/moddev/mc/basepower/migration/OreSpawnProfileMigrationTest.java new file mode 100644 index 00000000..5a5078ed --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/migration/OreSpawnProfileMigrationTest.java @@ -0,0 +1,172 @@ +package zone.moddev.mc.basepower.migration; + +import static org.junit.Assert.*; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class OreSpawnProfileMigrationTest { + @Rule public final TemporaryFolder temporary = new TemporaryFolder(); + + private static JsonObject legacyProfile() { + return JsonParser.parseString(""" + {"schema_version":7,"ores":{ + "poweradvantage:legacy/custom_oil":{"block":"poweradvantage:crude_oil", + "enabled":true,"dimension_selectors":{"orespawn:all_except_nether_end":{ + "enabled":true,"frequency":0.375,"min_y":16,"max_y":63}}}}, + "fluid_deposits":{},"history":"poweradvantage:crude_oil is a literal note"} + """).getAsJsonObject(); + } + + @Test public void migratesGlobalOverrideAndLockedWorldBeforeOreSpawnReadsThem() throws Exception { + AdvantageLegacyMigrations.registerPowerAdvantage(); + Path config = temporary.newFolder("config").toPath(); + Path global = config.resolve("orespawn-worldgen.json"); + Path override = config.resolve("basepower-orespawn.json"); + String original = legacyProfile().toString(); + Files.writeString(global, original); + Files.writeString(override, original); + OreSpawnProfileMigration.registerPowerProvider(config); + assertMigrated(global); + assertMigrated(override); + + Path world = temporary.newFolder("world").toPath(); + Path profile = Files.createDirectories(world.resolve("serverconfig")).resolve("orespawn-worldgen.json"); + Files.writeString(profile, original); + OreSpawnProfileMigration.migrateWorld(world); + assertMigrated(profile); + byte[] first = Files.readAllBytes(profile); + OreSpawnProfileMigration.migrateWorld(world); + assertArrayEquals(first, Files.readAllBytes(profile)); + try (var files = Files.list(profile.getParent())) { + assertEquals(1, files.filter(path -> path.getFileName().toString().endsWith(".bak")).count()); + } + } + + @Test public void absentProfileDoesNotCreateOrPopulateAWorld() throws Exception { + Path config = temporary.newFolder("empty-config").toPath(); + OreSpawnProfileMigration.registerPowerProvider(config); + Path absent = temporary.getRoot().toPath().resolve("absent-world"); + OreSpawnProfileMigration.migrateWorld(absent); + assertFalse(Files.exists(absent)); + try (var files = Files.list(config)) { assertEquals(0, files.count()); } + } + + @Test public void newWorldWithPreplacedProfileDoesNotNeedLevelDat() throws Exception { + AdvantageLegacyMigrations.registerPowerAdvantage(); + OreSpawnProfileMigration.registerPowerProvider(temporary.newFolder("new-world-config").toPath()); + Path world = temporary.newFolder("new-world").toPath(); + Path file = Files.createDirectories(world.resolve("serverconfig")).resolve("orespawn-worldgen.json"); + JsonObject source = JsonParser.parseString(""" + {"schema_version":7,"manage_vanilla_ores":false,"ores":{ + "minecraft:quartz_ore":{"enabled":true,"dimensions":{ + "minecraft:the_nether":{"enabled":true,"frequency":0.375}}}}} + """).getAsJsonObject(); + Files.writeString(file, source.toString()); + assertFalse(Files.exists(world.resolve("level.dat"))); + OreSpawnProfileMigration.migrateWorld(world); + JsonObject expected = source.deepCopy(); + expected.getAsJsonObject("ores").getAsJsonObject("minecraft:quartz_ore") + .addProperty("block", "minecraft:nether_quartz_ore"); + assertEquals(expected, JsonParser.parseString(Files.readString(file))); + assertFalse("Profile preparation must not manufacture world data", Files.exists(world.resolve("level.dat"))); + } + + @Test public void serverHookRunsAtHighestPriorityBeforeOreSpawnNormalListener() throws Exception { + var power = NativeMigrationMixinContract.read("zone/moddev/mc/basepower/PowerAdvantage"); + var constructor = NativeMigrationMixinContract.method(power, ""); + assertTrue("New worlds bypass the existing-level.dat datafix hook", NativeMigrationMixinContract.calls(constructor) + .stream().anyMatch(call -> call.owner.equals("zone/moddev/mc/basepower/migration/OreSpawnProfileMigration") + && call.name.equals("registerServerHook"))); + var coordinator = NativeMigrationMixinContract.read("zone/moddev/mc/basepower/migration/OreSpawnProfileMigration"); + var register = NativeMigrationMixinContract.method(coordinator, "registerServerHook"); + var analyzer = new org.objectweb.asm.tree.analysis.Analyzer<>(new org.objectweb.asm.tree.analysis.SourceInterpreter()); + var frames = analyzer.analyze(coordinator.name, register); + var registrations = NativeMigrationMixinContract.calls(register).stream() + .filter(call -> call.name.equals("addListener")).toList(); + assertEquals(1, registrations.size()); + var registration = registrations.getFirst(); + assertEquals("(BLjava/util/function/Consumer;)Lnet/minecraftforge/eventbus/api/listener/EventListener;", registration.desc); + var frame = frames[register.instructions.indexOf(registration)]; + int base = frame.getStackSize() - 3; + assertTrue(frame.getStack(base).insns.stream().anyMatch(node -> + node instanceof org.objectweb.asm.tree.FieldInsnNode field + && field.owner.equals("net/minecraftforge/event/server/ServerAboutToStartEvent") && field.name.equals("BUS"))); + assertTrue(frame.getStack(base + 1).insns.stream().anyMatch(node -> + node instanceof org.objectweb.asm.tree.IntInsnNode constant + && constant.operand == net.minecraftforge.eventbus.api.listener.Priority.HIGHEST)); + assertTrue(frame.getStack(base + 2).insns.stream().anyMatch(node -> { + if (!(node instanceof org.objectweb.asm.tree.InvokeDynamicInsnNode dynamic)) return false; + return java.util.Arrays.stream(dynamic.bsmArgs).anyMatch(argument -> argument instanceof org.objectweb.asm.Handle handle + && handle.getOwner().equals(coordinator.name) && handle.getName().equals("prepareServerProfile")); + })); + var prepare = NativeMigrationMixinContract.method(coordinator, "prepareServerProfile"); + var calls = NativeMigrationMixinContract.calls(prepare); + assertTrue(calls.stream().anyMatch(call -> call.owner.equals("net/minecraft/server/MinecraftServer") && call.name.equals("getWorldPath"))); + assertTrue(calls.stream().anyMatch(call -> call.owner.equals(coordinator.name) && call.name.equals("migrateWorld"))); + assertTrue("No cache reload or server tick may precede profile repair", calls.stream().noneMatch(call -> + call.name.equals("reloadActiveProfile") || call.name.equals("tickServer"))); + } + + @Test public void targetValidationUsesDeclaredCodecsWithoutConstructingModBlocks() { + AdvantageLegacyMigrations.registerPowerAdvantage(); + assertTrue(OreSpawnProfileMigration.declaredTarget("basepower:crude_oil")); + assertTrue(OreSpawnProfileMigration.declaredTarget("basepower:refined_oil")); + assertTrue(OreSpawnProfileMigration.declaredTarget("minecraft:nether_quartz_ore")); + assertFalse(OreSpawnProfileMigration.declaredTarget("basepower:no_such_block")); + assertFalse(OreSpawnProfileMigration.declaredTarget("minecraft:air")); + assertFalse(OreSpawnProfileMigration.declaredTarget("bad identifier")); + } + + @Test public void selectedWorldProfileIsPreparedBeforeWorldRegistryMutation() throws Exception { + var hook = NativeMigrationMixinContract.read("zone/moddev/mc/basepower/migration/LegacyWorldDataHook"); + var method = NativeMigrationMixinContract.method(hook, "prepareLegacyWorld"); + var calls = NativeMigrationMixinContract.calls(method); + int profile = -1, registry = -1; + for (int index = 0; index < calls.size(); index++) { + var call = calls.get(index); + if (call.owner.equals("zone/moddev/mc/basepower/migration/OreSpawnProfileMigration") + && call.name.equals("migrateWorld")) profile = index; + if (call.owner.equals(hook.name) && call.name.equals("prepareForgeRegistryMetadata")) registry = index; + } + assertTrue("Saved generation choices must be checked before world data is changed", + profile >= 0 && registry > profile); + } + + @Test public void ambiguousActiveDefaultStopsBeforeReplacingAProfile() throws Exception { + AdvantageLegacyMigrations.registerPowerAdvantage(); + Path config = temporary.newFolder("collision").toPath(); + JsonObject source = legacyProfile(); + JsonObject current = JsonParser.parseString(""" + {"block":"basepower:crude_oil","enabled":true, + "dimensions":{"minecraft:overworld":{"enabled":true,"frequency":0.08}}} + """).getAsJsonObject(); + source.getAsJsonObject("fluid_deposits").add("basepower:fluid_deposit/desert_crude_oil", current); + Path file = config.resolve("orespawn-worldgen.json"); + Files.writeString(file, source.toString()); + byte[] before = Files.readAllBytes(file); + try { + OreSpawnProfileMigration.registerPowerProvider(config); + fail("A colliding saved choice requires review, not a silent terrain change"); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().contains("needs review")); + } + assertArrayEquals(before, Files.readAllBytes(file)); + } + + private static void assertMigrated(Path path) throws Exception { + JsonObject result = JsonParser.parseString(Files.readString(path)).getAsJsonObject(); + JsonObject legacy = result.getAsJsonObject("ores").getAsJsonObject("poweradvantage:legacy/custom_oil"); + assertEquals("basepower:crude_oil", legacy.get("block").getAsString()); + assertEquals(0.375, legacy.getAsJsonObject("dimension_selectors") + .getAsJsonObject("orespawn:all_except_nether_end").get("frequency").getAsDouble(), 0.0); + assertEquals("poweradvantage:crude_oil is a literal note", result.get("history").getAsString()); + assertFalse(result.getAsJsonObject("fluid_deposits") + .getAsJsonObject("basepower:fluid_deposit/desert_crude_oil").get("enabled").getAsBoolean()); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/registry/FuelRegistryTest.java b/src/test/java/zone/moddev/mc/basepower/registry/FuelRegistryTest.java new file mode 100644 index 00000000..9e0314b7 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/registry/FuelRegistryTest.java @@ -0,0 +1,55 @@ +package zone.moddev.mc.basepower.registry; + +import java.util.function.Function; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class FuelRegistryTest { + @BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + } + + @Test public void lastOrdinaryFuelItemLeavesAnEmptySlot() { + assertTrue(FuelRegistry.decrementFuelItem(new ItemStack(Items.COAL)).isEmpty()); + } + + @Test public void largerFuelStackConsumesExactlyOneItem() { + ItemStack stack = new ItemStack(Items.COAL, 3); + assertSame(stack, FuelRegistry.decrementFuelItem(stack)); + assertEquals(Items.COAL, stack.getItem()); + assertEquals(2, stack.getCount()); + } + + @Test public void lastLavaBucketReturnsExactlyOneEmptyBucket() { + ItemStack stack = new ItemStack(Items.LAVA_BUCKET); + assertNotNull("Native crafting remainder must exist before consumption", stack.getCraftingRemainder()); + ItemStack result = FuelRegistry.decrementFuelItem(stack); + assertEquals(Items.BUCKET, result.getItem()); + assertEquals(1, result.getCount()); + } + + @Test public void nativeEmptyAndLegacyNullInputsKeepTheirMeaning() { + assertSame(ItemStack.EMPTY, FuelRegistry.decrementFuelItem(ItemStack.EMPTY)); + assertNull(FuelRegistry.decrementFuelItem(null)); + } + + @Test public void explicitPostBurnAlgorithmsStillOverrideNativeRemainders() { + Function algorithm = stack -> { + assertEquals(3, stack.getCount()); + return new ItemStack(Items.CHARCOAL, 2); + }; + FuelRegistry registry = FuelRegistry.getInstance(); + registry.registerPostBurnItem(Items.STICK, algorithm); + try { + ItemStack result = FuelRegistry.decrementFuelItem(new ItemStack(Items.STICK, 3)); + assertEquals(Items.CHARCOAL, result.getItem()); + assertEquals(2, result.getCount()); + } finally { + assertTrue(registry.getPostBurnAlgorithmsForItem(Items.STICK).remove(algorithm)); + } + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/resources/BuildWorkflowContractTest.java b/src/test/java/zone/moddev/mc/basepower/resources/BuildWorkflowContractTest.java index 6c5c0790..0d5723d7 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/BuildWorkflowContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/BuildWorkflowContractTest.java @@ -96,7 +96,9 @@ private static void requireWorkflowTargets(String workflow, String branch) { assertTrue(name, workflow.contains("master-" + pins.getProperty("minecraft_version"))); assertTrue(name, workflow.contains("java-version: '25.0.3+9.0.LTS'")); assertTrue(name, workflow.contains("java-version: '" + pins.getProperty("java_setup_version") + "'")); - assertTrue(name, workflow.contains("java-version: '8.0.502+7'")); + assertFalse(name, workflow.contains("java-version: '8.0.502+7'")); + assertTrue(name, workflow.contains("-Porg.gradle.java.installations.paths=$JAVA_HOME")); + assertFalse(name, workflow.contains("JAVA_HOME_8_X64")); assertFalse(name, workflow.contains("JAVA_HOME_16_X64")); assertTrue(name, workflow.contains("-Porg.gradle.java.installations.paths=")); assertTrue(name, workflow.contains("-Porg.gradle.java.installations.auto-detect=false")); diff --git a/src/test/java/zone/moddev/mc/basepower/resources/ClientResourceContractTest.java b/src/test/java/zone/moddev/mc/basepower/resources/ClientResourceContractTest.java index d6934523..a2443e31 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/ClientResourceContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/ClientResourceContractTest.java @@ -13,6 +13,8 @@ import java.nio.file.Paths; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.HashSet; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -70,22 +72,28 @@ public class ClientResourceContractTest { assertEquals(file.toString(), "basepower:item/" + name.replace(".json", ""), node.get("model").getAsString()); verifyReference(node.get("model").getAsString(), "models", ".json", file); } - JsonObject atlas = read(ROOT.resolve("assets/minecraft/atlases/items.json")); - assertEquals("The item atlas must not shadow block or material sprites", 1, - atlas.getAsJsonArray("sources").size()); - JsonObject source = atlas.getAsJsonArray("sources").get(0).getAsJsonObject(); - assertEquals("directory", source.get("type").getAsString()); - assertEquals("items", source.get("source").getAsString()); - assertEquals("items/", source.get("prefix").getAsString()); + Set blocks = atlasResources("blocks"), items = atlasResources("items"); + assertTrue("The item atlas must not shadow block or material sprites", + java.util.Collections.disjoint(blocks, items)); + for (String item : items) assertTrue("Only item artwork belongs in the item atlas: " + item, + item.startsWith("basepower:items/")); + for (String fluid : new String[] {"crude_oil_still", "crude_oil_flow", "refined_oil_still", "refined_oil_flow"}) { + assertTrue("Missing manually requested fluid sprite " + fluid, blocks.contains("basepower:blocks/" + fluid)); + } + } + + @Test public void developmentProfilePinsTheActualNative2612Family() throws IOException { String build = Files.readString(Paths.get("build.gradle"), StandardCharsets.UTF_8); - assertTrue("Combined Eclipse defaults must select the native 1.21.11 profile", + assertTrue("Combined Eclipse defaults must select the native 26.1.2 profile", + build.contains("'test-rig/profiles/native2612/dev-core.json'")); + assertFalse("A CLI override must not hide a stale 1.21.11 default", build.contains("'test-rig/profiles/native12111/dev-core.json'")); assertFalse("A CLI override must not hide a stale 1.21.1 default", build.contains("'test-rig/profiles/native1211/dev-core.json'")); - JsonObject profile = read(Paths.get("test-rig/profiles/native12111/dev-core.json")); + JsonObject profile = read(Paths.get("test-rig/profiles/native2612/dev-core.json")); assertEquals("development", profile.get("mode").getAsString()); - assertEquals("1.21.11", profile.get("minecraftVersion").getAsString()); - assertEquals("61.1.0", profile.get("forgeVersion").getAsString()); + assertEquals("26.1.2", profile.get("minecraftVersion").getAsString()); + assertEquals("64.1.5", profile.get("forgeVersion").getAsString()); java.util.Properties pins = new java.util.Properties(); try (Reader reader = Files.newBufferedReader(Paths.get("gradle.properties"), StandardCharsets.UTF_8)) { pins.load(reader); @@ -151,6 +159,25 @@ private static void verifyReference(String reference, String directory, String s assertTrue(file + " references missing resource " + resource, found); } + private static Set atlasResources(String name) throws IOException { + Path file = ROOT.resolve("assets/minecraft/atlases/" + name + ".json"); + JsonObject atlas = read(file); + assertEquals(file.toString(), Set.of("sources"), atlas.keySet()); + Set resources = new HashSet<>(); + for (JsonElement value : atlas.getAsJsonArray("sources")) { + JsonObject source = value.getAsJsonObject(); + assertEquals("No global imports, aliases or hidden selectors in " + file, + Set.of("type", "resource"), source.keySet()); + assertEquals(file.toString(), "minecraft:single", source.get("type").getAsString()); + String resource = source.get("resource").getAsString(); + assertTrue("Foreign sprite in " + file + ": " + resource, resource.startsWith("basepower:")); + assertTrue("Duplicate sprite in " + file + ": " + resource, resources.add(resource)); + verifyReference(resource, "textures", ".png", file); + } + assertFalse("An empty atlas would hide the registration failure", resources.isEmpty()); + return resources; + } + private static JsonObject read(Path file) throws IOException { try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { return JsonParser.parseReader(reader).getAsJsonObject(); diff --git a/src/test/java/zone/moddev/mc/basepower/resources/Native2612PackFormatTest.java b/src/test/java/zone/moddev/mc/basepower/resources/Native2612PackFormatTest.java new file mode 100644 index 00000000..c0574ef1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/resources/Native2612PackFormatTest.java @@ -0,0 +1,56 @@ +package zone.moddev.mc.basepower.resources; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import java.nio.file.Files; +import java.nio.file.Path; +import net.minecraft.SharedConstants; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.metadata.pack.PackFormat; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class Native2612PackFormatTest { + @BeforeClass public static void detectActualEngine() { + SharedConstants.tryDetectVersion(); + } + + @Test public void actualEngineDeclaresTheExactTargetFormats() { + var version = SharedConstants.getCurrentVersion(); + assertEquals("26.1.2", version.name()); + assertEquals(4790, version.dataVersion().version()); + assertEquals(PackFormat.of(84, 0), version.packVersion(PackType.CLIENT_RESOURCES)); + assertEquals(PackFormat.of(101, 1), version.packVersion(PackType.SERVER_DATA)); + assertEquals(25, Runtime.version().feature()); + } + + @Test public void rootMetadataUsesExactlyTheNativeDataVersion() throws Exception { + JsonObject metadata = JsonParser.parseString(Files.readString(Path.of("src/main/resources/pack.mcmeta"))) + .getAsJsonObject().getAsJsonObject("pack"); + var range = PackFormat.packCodec(PackType.SERVER_DATA).codec().parse(JsonOps.INSTANCE, metadata).getOrThrow(); + assertEquals(PackFormat.of(101, 1), range.minInclusive()); + assertEquals(PackFormat.of(101, 1), range.maxInclusive()); + assertFalse(metadata.has("pack_format")); + assertFalse(metadata.has("supported_formats")); + assertTrue(range.isValueInRange(SharedConstants.getCurrentVersion().packVersion(PackType.SERVER_DATA))); + assertFalse(range.isValueInRange(SharedConstants.getCurrentVersion().packVersion(PackType.CLIENT_RESOURCES))); + } + + @Test public void nativeCodecRejectsAnInvertedRange() { + var malformed = JsonParser.parseString("{\"min_format\":[101,2],\"max_format\":[101,1]}"); + assertTrue(PackFormat.packCodec(PackType.SERVER_DATA).codec().parse(JsonOps.INSTANCE, malformed).error().isPresent()); + } + + @Test public void serverOnlyRangeCannotQualifyTheClientOrOlderTargets() { + var dataOnly = JsonParser.parseString("{\"min_format\":[101,1],\"max_format\":[101,1]}"); + var range = PackFormat.packCodec(PackType.CLIENT_RESOURCES).codec().parse(JsonOps.INSTANCE, dataOnly).getOrThrow(); + assertFalse(range.isValueInRange(PackFormat.of(84, 0))); + assertFalse(range.isValueInRange(PackFormat.of(75, 0))); + assertFalse(range.isValueInRange(PackFormat.of(94, 1))); + assertFalse(range.isValueInRange(PackFormat.of(101, 0))); + assertFalse(range.isValueInRange(PackFormat.of(101, 2))); + assertTrue(range.isValueInRange(PackFormat.of(101, 1))); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/resources/Native2612VillagerTradeResourceTest.java b/src/test/java/zone/moddev/mc/basepower/resources/Native2612VillagerTradeResourceTest.java new file mode 100644 index 00000000..0c511c1b --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/resources/Native2612VillagerTradeResourceTest.java @@ -0,0 +1,147 @@ +package zone.moddev.mc.basepower.resources; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import net.minecraft.SharedConstants; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.server.Bootstrap; +import net.minecraft.tags.TagFile; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.trading.TradeCost; +import net.minecraft.world.level.storage.loot.functions.LootItemFunctions; +import net.minecraft.world.level.storage.loot.functions.SetItemCountFunction; +import net.minecraft.world.level.storage.loot.providers.number.ConstantValue; +import net.minecraft.world.level.storage.loot.providers.number.NumberProviders; +import net.minecraft.world.level.storage.loot.providers.number.UniformGenerator; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Native resource/field codecs only. Full Power-item trade decoding and offers need the packaged server. */ +public class Native2612VillagerTradeResourceTest { + private static final Path DATA = Path.of("src/main/resources/data"); + + @BeforeClass public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + assertEquals("26.1.2", SharedConstants.getCurrentVersion().name()); + } + + @Test public void nativeTradeFieldsRetainBothHistoricalExchangeRates() throws Exception { + verify(readTrade("emerald_sprockets"), "basepower:sprocket", 1, 4); + verify(readTrade("emerald_bioplastic"), "basepower:bioplastic_ingot", 4, 8); + } + + @Test public void nativeProfessionTagsAppendExactlyTheTwoRequiredTrades() throws Exception { + verifyTag(readTag("toolsmith"), "basepower:emerald_sprockets"); + verifyTag(readTag("farmer"), "basepower:emerald_bioplastic"); + } + + @Test public void changedItemsRatesLimitsAndDiscountCannotPassTheResourceContract() throws Exception { + JsonObject original = readTrade("emerald_sprockets"); + var wrongItem = original.deepCopy(); + wrongItem.getAsJsonObject("gives").addProperty("id", "minecraft:dirt"); + rejected(wrongItem); + for (String field : new String[]{"max_uses", "xp", "reputation_discount"}) { + var changed = original.deepCopy(); + changed.addProperty(field, original.get(field).getAsInt() + 1); + rejected(changed); + } + var wrongCost = original.deepCopy(); + wrongCost.getAsJsonObject("wants").addProperty("count", 2); + rejected(wrongCost); + var wrongRange = original.deepCopy(); + wrongRange.getAsJsonArray("given_item_modifiers").get(0).getAsJsonObject() + .getAsJsonObject("count").addProperty("max", 5); + rejected(wrongRange); + var additive = original.deepCopy(); + additive.getAsJsonArray("given_item_modifiers").get(0).getAsJsonObject().addProperty("add", true); + rejected(additive); + var unknown = original.deepCopy(); + unknown.addProperty("unreviewed_field", true); + rejected(unknown); + } + + @Test public void replacementAndWrongProfessionMembershipCannotPass() throws Exception { + var replacement = readTag("toolsmith"); + replacement.addProperty("replace", true); + assertThrows(AssertionError.class, () -> verifyTag(replacement, "basepower:emerald_sprockets")); + assertThrows(AssertionError.class, () -> verifyTag(readTag("farmer"), "basepower:emerald_sprockets")); + } + + @Test public void strictProviderIdentityRejectsNativeFallbackForUnknownTypes() throws Exception { + var trade = readTrade("emerald_sprockets"); + var modifier = trade.getAsJsonArray("given_item_modifiers").get(0); + modifier.getAsJsonObject().getAsJsonObject("count").addProperty("type", "basepower:missing_count_provider"); + // NumberProviders.CODEC explicitly falls back to an untyped UniformGenerator. + assertTrue(LootItemFunctions.ROOT_CODEC.parse(JsonOps.INSTANCE, modifier).isSuccess()); + rejected(trade); + modifier.getAsJsonObject().addProperty("function", "basepower:missing_item_function"); + assertTrue(LootItemFunctions.ROOT_CODEC.parse(JsonOps.INSTANCE, modifier).isError()); + } + + private static void verify(JsonObject trade, String item, int min, int max) { + assertEquals(Set.of("wants", "gives", "max_uses", "xp", "reputation_discount", "given_item_modifiers"), trade.keySet()); + assertEquals(Set.of("id", "count"), trade.getAsJsonObject("wants").keySet()); + assertEquals(Set.of("id"), trade.getAsJsonObject("gives").keySet()); + assertEquals(item, trade.getAsJsonObject("gives").get("id").getAsString()); + var ops = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY).createSerializationContext(JsonOps.INSTANCE); + var cost = TradeCost.CODEC.parse(ops, trade.get("wants")).getOrThrow(); + assertSame(Items.EMERALD, cost.item().value()); + assertEquals(1, constant(cost.count())); + assertEquals(7, constant(NumberProviders.CODEC.parse(ops, trade.get("max_uses")).getOrThrow())); + assertEquals(1, constant(NumberProviders.CODEC.parse(ops, trade.get("xp")).getOrThrow())); + assertEquals(0, constant(NumberProviders.CODEC.parse(ops, trade.get("reputation_discount")).getOrThrow())); + var modifiers = trade.getAsJsonArray("given_item_modifiers"); + assertEquals(1, modifiers.size()); + var modifier = modifiers.get(0).getAsJsonObject(); + assertEquals(Set.of("function", "count"), modifier.keySet()); + assertEquals("minecraft:set_count", modifier.get("function").getAsString()); + var sourceCount = modifier.getAsJsonObject("count"); + assertEquals(Set.of("type", "min", "max"), sourceCount.keySet()); + assertEquals("minecraft:uniform", sourceCount.get("type").getAsString()); + var function = LootItemFunctions.ROOT_CODEC.parse(ops, modifier).getOrThrow(); + assertTrue(function instanceof SetItemCountFunction); + var encoded = LootItemFunctions.ROOT_CODEC.encodeStart(ops, function).getOrThrow().getAsJsonObject(); + assertFalse(encoded.get("add").getAsBoolean()); + var count = NumberProviders.CODEC.parse(ops, encoded.get("count")).getOrThrow(); + assertTrue(count instanceof UniformGenerator); + var uniform = (UniformGenerator) count; + assertEquals(min, constant(uniform.min())); + assertEquals(max, constant(uniform.max())); + } + + private static int constant(net.minecraft.world.level.storage.loot.providers.number.NumberProvider value) { + assertTrue(value instanceof ConstantValue); + float number = ((ConstantValue) value).value(); + assertEquals((int) number, number, 0F); + return (int) number; + } + + private static void verifyTag(JsonObject source, String id) { + assertEquals(Set.of("replace", "values"), source.keySet()); + TagFile decoded = TagFile.CODEC.parse(JsonOps.INSTANCE, source).getOrThrow(); + assertFalse(decoded.replace()); + assertTrue(decoded.remove().isEmpty()); + assertEquals(1, decoded.entries().size()); + var encoded = TagFile.CODEC.encodeStart(JsonOps.INSTANCE, decoded).getOrThrow().getAsJsonObject(); + assertEquals(id, encoded.getAsJsonArray("values").get(0).getAsString()); + } + + private static void rejected(JsonObject value) { + assertThrows(AssertionError.class, () -> verify(value, "basepower:sprocket", 1, 4)); + } + + private static JsonObject readTrade(String name) throws Exception { + return JsonParser.parseString(Files.readString(DATA.resolve("basepower/villager_trade/" + name + ".json"))).getAsJsonObject(); + } + + private static JsonObject readTag(String profession) throws Exception { + return JsonParser.parseString(Files.readString(DATA.resolve("minecraft/tags/villager_trade/" + profession + "/level_1.json"))).getAsJsonObject(); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativeGuiExtractionFixture.java b/src/test/java/zone/moddev/mc/basepower/resources/NativeGuiExtractionFixture.java new file mode 100644 index 00000000..177aa39c --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativeGuiExtractionFixture.java @@ -0,0 +1,86 @@ +package zone.moddev.mc.basepower.resources; + +import com.mojang.blaze3d.textures.GpuTextureView; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.state.gui.GuiRenderState; +import net.minecraft.client.renderer.texture.SimpleTexture; +import net.minecraft.client.renderer.texture.SpriteContents; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.renderer.texture.TextureManager; +import net.minecraft.resources.Identifier; +import org.joml.Matrix3x2fStack; + +/** CPU extraction only: real native queues/scissors, but no texture upload or GPU rendering. */ +public final class NativeGuiExtractionFixture { + public final Minecraft minecraft; + public final GuiGraphicsExtractor graphics; + public final Matrix3x2fStack pose = new Matrix3x2fStack(16); + private final Map textures = new HashMap<>(); + + public NativeGuiExtractionFixture(GuiRenderState target) throws Exception { + // Skip GPU startup only; every extraction/scissor operation runs the native implementation. + minecraft = allocate(Minecraft.class); + var manager = allocate(TextureManager.class); + set(manager, "byPath", textures); + set(minecraft, "textureManager", manager); + set(minecraft, "gui", allocate(Gui.class)); + graphics = allocate(GuiGraphicsExtractor.class); + set(graphics, "minecraft", minecraft); + set(graphics, "pose", pose); + set(graphics, "guiRenderState", target); + var constructor = Class.forName(GuiGraphicsExtractor.class.getName() + "$ScissorStack") + .getDeclaredConstructor(); + constructor.setAccessible(true); + set(graphics, "scissorStack", constructor.newInstance()); + } + + public GpuTextureView texture(Identifier id) throws Exception { + var texture = allocate(SimpleTexture.class); + var view = new GpuTextureView(null, 0, 1) { + @Override public void close() { throw new UnsupportedOperationException("CPU fixture"); } + @Override public boolean isClosed() { return false; } + }; + set(texture, "textureView", view); + textures.put(id, texture); + return view; + } + + public TextureAtlasSprite sprite(Identifier atlas) throws Exception { + texture(atlas); + var contents = allocate(SpriteContents.class); + set(contents, "width", 16); + set(contents, "height", 16); + return new TextureAtlasSprite(atlas, contents, 64, 64, 16, 32, 0); + } + + public static T allocate(Class type) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field instance = allocator.getDeclaredField("theUnsafe"); + instance.setAccessible(true); + return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(instance.get(null), type)); + } + + public static Object get(Object instance, String name) throws Exception { + return field(instance, name).get(instance); + } + + public static void set(Object instance, String name, Object value) throws Exception { + field(instance, name).set(instance, value); + } + + private static Field field(Object instance, String name) throws Exception { + for (Class type = instance.getClass(); type != null; type = type.getSuperclass()) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException ignored) {} + } + throw new NoSuchFieldException(name); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativePackFormatContractTest.java b/src/test/java/zone/moddev/mc/basepower/resources/NativePackFormatContractTest.java index 6d1cb3cd..c5f6fa8a 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/NativePackFormatContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativePackFormatContractTest.java @@ -44,8 +44,14 @@ public class NativePackFormatContractTest { SharedConstants.RESOURCE_PACK_FORMAT_MAJOR, SharedConstants.RESOURCE_PACK_FORMAT_MINOR))); assertTrue(server.supportedFormats().isValueInRange(PackFormat.of( SharedConstants.DATA_PACK_FORMAT_MAJOR, SharedConstants.DATA_PACK_FORMAT_MINOR))); - assertFalse(client.supportedFormats().isValueInRange(PackFormat.of(74))); - assertFalse(server.supportedFormats().isValueInRange(PackFormat.of(95))); + assertEquals(PackFormat.of(84, 0).minorRange(), client.supportedFormats()); + assertEquals(PackFormat.of(101, 1), server.supportedFormats().minInclusive()); + assertEquals(PackFormat.of(101, 1), server.supportedFormats().maxInclusive()); + assertFalse(client.supportedFormats().isValueInRange(PackFormat.of(83))); + assertFalse(client.supportedFormats().isValueInRange(PackFormat.of(85))); + assertFalse(server.supportedFormats().isValueInRange(PackFormat.of(101, 0))); + assertFalse(server.supportedFormats().isValueInRange(PackFormat.of(101, 2))); + assertFalse(server.supportedFormats().isValueInRange(PackFormat.of(102))); assertFalse(client.supportedFormats().isValueInRange(PackFormat.of(94, 1))); assertFalse(server.supportedFormats().isValueInRange(PackFormat.of(75, 0))); } diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativePackRangeContractTest.java b/src/test/java/zone/moddev/mc/basepower/resources/NativePackRangeContractTest.java index 0883853b..a780615d 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/NativePackRangeContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativePackRangeContractTest.java @@ -20,11 +20,13 @@ public class NativePackRangeContractTest { JsonObject pack = side.getAsJsonObject("nativeMetadata"); var range = PackFormat.packCodec(type).codec().parse(JsonOps.INSTANCE, pack).getOrThrow(); assertEquals(type.toString(), type == PackType.CLIENT_RESOURCES, - range.isValueInRange(PackFormat.of(75, 0))); + range.isValueInRange(PackFormat.of(84, 0))); assertEquals(type.toString(), type == PackType.SERVER_DATA, - range.isValueInRange(PackFormat.of(94, 1))); - assertFalse(range.isValueInRange(PackFormat.of(74))); - assertFalse(range.isValueInRange(PackFormat.of(95))); + range.isValueInRange(PackFormat.of(101, 1))); + assertFalse(range.isValueInRange(PackFormat.of(83))); + assertFalse(range.isValueInRange(PackFormat.of(102))); + assertFalse(range.isValueInRange(PackFormat.of(75, 0))); + assertFalse(range.isValueInRange(PackFormat.of(94, 1))); } } @@ -46,11 +48,12 @@ public class NativePackRangeContractTest { } @Test public void dataOnlyRangeDoesNotQualifyClientResources() { - JsonObject dataOnly = JsonParser.parseString("{\"min_format\":[94,1],\"max_format\":[94,1]}") + JsonObject dataOnly = JsonParser.parseString("{\"min_format\":[101,1],\"max_format\":[101,1]}") .getAsJsonObject(); var range = PackFormat.packCodec(PackType.CLIENT_RESOURCES).codec() .parse(JsonOps.INSTANCE, dataOnly).getOrThrow(); - assertFalse(range.isValueInRange(PackFormat.of(75, 0))); - assertTrue(range.isValueInRange(PackFormat.of(94, 1))); + assertFalse(range.isValueInRange(PackFormat.of(84, 0))); + assertFalse(range.isValueInRange(PackFormat.of(94, 1))); + assertTrue(range.isValueInRange(PackFormat.of(101, 1))); } } diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativePlatformContractTest.java b/src/test/java/zone/moddev/mc/basepower/resources/NativePlatformContractTest.java index bd869611..bfeda6cc 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/NativePlatformContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativePlatformContractTest.java @@ -24,11 +24,20 @@ public class NativePlatformContractTest { assertEquals(1, config.getAsJsonObject("injectors").get("defaultRequire").getAsInt()); java.util.Set hooks = new java.util.HashSet<>(); config.getAsJsonArray("mixins").forEach(value -> hooks.add(value.getAsString())); - assertEquals(java.util.Set.of("BlockStateDataMixin", "LevelStorageSourceMixin", "PlayerDataStorageMixin", + assertEquals(java.util.Set.of("BlockStateDataMixin", "LevelStorageSourceMixin", "SelectedWorldStorageMixin", "PlayerDataStorageMixin", "SimpleRegionStorageMixin", "WorldGenRegionMixin"), hooks); for (String name : hooks) assertNotNull(read("zone/moddev/mc/basepower/mixin/" + name)); } + @Test public void java25BytecodeIsIndependentOfNativeMixinCompatibilityLevel() throws Exception { + assertEquals(25, Runtime.version().feature()); + assertEquals(69, read("cyano/poweradvantage/api/simple/SimpleMachineGUI").version & 0xFFFF); + assertEquals(69, read("zone/moddev/mc/basepower/mixin/SelectedWorldStorageMixin").version & 0xFFFF); + var levels = org.spongepowered.asm.mixin.MixinEnvironment.CompatibilityLevel.values(); + assertTrue(java.util.Arrays.stream(levels).anyMatch(level -> level.name().equals("JAVA_21"))); + assertFalse(java.util.Arrays.stream(levels).anyMatch(level -> level.name().equals("JAVA_25"))); + } + @Test public void nativeRecipeAndAdvancementLoadingShareThePostponedTagContext() throws Exception { ClassNode node = read("net/minecraft/server/ReloadableServerResources"); MethodNode constructor = node.methods.stream().filter(method -> method.name.equals("")) diff --git a/src/test/java/zone/moddev/mc/basepower/testing/NativePackProcessContractTest.java b/src/test/java/zone/moddev/mc/basepower/testing/NativePackProcessContractTest.java index 6aa6b453..6574d62f 100644 --- a/src/test/java/zone/moddev/mc/basepower/testing/NativePackProcessContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/testing/NativePackProcessContractTest.java @@ -27,6 +27,10 @@ public class NativePackProcessContractTest { @Test public void nativeProofActuallyRunsWithTerminalDisabled() throws Exception { var proof = NativePackResourceProof.read(); + assertEquals("26.1.2", proof.get("minecraft").getAsString()); + assertEquals("RESOURCE_PACK_ONLY", proof.get("discoveryScope").getAsString()); + assertEquals("64.1.5", proof.get("forge").getAsString()); + assertEquals(25, proof.get("javaFeature").getAsInt()); assertTrue(proof.get("interactiveTerminalDisabled").getAsBoolean()); assertEquals(0, proof.getAsJsonArray("warningsAndErrors").size()); assertTrue(proof.getAsJsonObject("CLIENT_RESOURCES").get("resourcesVerified").getAsInt() > 0); diff --git a/src/test/java/zone/moddev/mc/basepower/testing/NativePackResourceProof.java b/src/test/java/zone/moddev/mc/basepower/testing/NativePackResourceProof.java index 6aafd5cd..d07fb9ce 100644 --- a/src/test/java/zone/moddev/mc/basepower/testing/NativePackResourceProof.java +++ b/src/test/java/zone/moddev/mc/basepower/testing/NativePackResourceProof.java @@ -37,7 +37,7 @@ import org.apache.logging.log4j.core.config.Property; import org.apache.logging.log4j.core.layout.PatternLayout; -/** Runs real Forge discovery in a child VM so ModList and native registry state cannot leak into other tests. */ +/** Runs native Forge resource loading in an isolated VM, not dependency or gameplay qualification. */ public final class NativePackResourceProof { private static JsonObject receipt; @@ -47,7 +47,7 @@ public static synchronized JsonObject read() throws Exception { if (receipt != null) return receipt.deepCopy(); Path evidenceRoot = Path.of("build/native-pack-resources").toAbsolutePath(); Files.createDirectories(evidenceRoot); - Path evidence = Files.createTempDirectory(evidenceRoot, "forge61-"); + Path evidence = Files.createTempDirectory(evidenceRoot, "forge64-"); String classpath = Arrays.stream(System.getProperty("basepower.nativePackClasspath", System.getProperty("java.class.path")).split(java.util.regex.Pattern.quote(java.io.File.pathSeparator))) .map(entry -> new java.io.File(entry).getAbsolutePath()) @@ -90,6 +90,15 @@ public static void main(String[] args) throws Exception { throw new AssertionError("The redirected proof child must not initialise an interactive terminal"); } SharedConstants.tryDetectVersion(); + if (Runtime.version().feature() != 25 + || !"26.1.2".equals(SharedConstants.getCurrentVersion().name()) + || !"64.1.5".equals(net.minecraftforge.versions.forge.ForgeVersion.getVersion()) + || !net.minecraft.server.packs.metadata.pack.PackFormat.of(84, 0).equals( + SharedConstants.getCurrentVersion().packVersion(PackType.CLIENT_RESOURCES)) + || !net.minecraft.server.packs.metadata.pack.PackFormat.of(101, 1).equals( + SharedConstants.getCurrentVersion().packVersion(PackType.SERVER_DATA))) { + throw new AssertionError("Resource proof requires actual Forge 64.1.5 / Minecraft 26.1.2 on Java 25"); + } Bootstrap.bootStrap(); Path resources = Path.of(args[0]); Path evidence = Path.of(args[1]); @@ -107,7 +116,7 @@ public static void main(String[] args) throws Exception { } ModFile mod = new ModFile(SecureJar.from(jar), new ForgeDevLocator(), ModFileParser::modsTomlParser); if (mod.getModFileInfo().showAsResourcePack()) throw new AssertionError("Power must use Forge's hidden client pack"); - ModList.of(List.of(mod), mod.getModInfos().stream().map(ModInfo.class::cast).toList()); + initializeResourceDiscovery(mod); List diagnostics = new ArrayList<>(); AbstractAppender appender = new AbstractAppender("PowerPackContract", null, PatternLayout.createDefaultLayout(), false, Property.EMPTY_ARRAY) { @@ -120,6 +129,10 @@ public static void main(String[] args) throws Exception { logger.addAppender(appender); JsonObject result = new JsonObject(); result.addProperty("interactiveTerminalDisabled", true); + result.addProperty("discoveryScope", "RESOURCE_PACK_ONLY"); + result.addProperty("minecraft", SharedConstants.getCurrentVersion().name()); + result.addProperty("forge", net.minecraftforge.versions.forge.ForgeVersion.getVersion()); + result.addProperty("javaFeature", Runtime.version().feature()); JsonArray dependencies = new JsonArray(); for (var dependency : mod.getModInfos().getFirst().getDependencies()) { JsonObject entry = new JsonObject(); @@ -160,8 +173,10 @@ public static void main(String[] args) throws Exception { side.addProperty("resourcesClass", opened.getClass().getName()); side.addProperty("acceptsClient", formats.isValueInRange(SharedConstants.getCurrentVersion().packVersion(PackType.CLIENT_RESOURCES))); side.addProperty("acceptsServer", formats.isValueInRange(SharedConstants.getCurrentVersion().packVersion(PackType.SERVER_DATA))); - side.addProperty("accepts74", formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(74))); - side.addProperty("accepts95", formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(95))); + side.addProperty("accepts83", formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(83))); + side.addProperty("accepts102", formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(102))); + side.addProperty("acceptsPreviousClient", formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(75, 0))); + side.addProperty("acceptsPreviousServer", formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(94, 1))); side.add("nativeMetadata", encoded); if (type == PackType.CLIENT_RESOURCES) { if (opened.getChildren() == null || opened.getChildren().size() != 1) { @@ -208,4 +223,33 @@ public static void main(String[] args) throws Exception { if (!diagnostics.isEmpty()) throw new AssertionError("Native Forge resource diagnostics: " + diagnostics); System.out.println("BASEPOWER_NATIVE_PACK_RESOURCES_PASS " + result); } + + private static void initializeResourceDiscovery(ModFile mod) throws Exception { + if (net.minecraftforge.fml.loading.FMLLoader.getGamePath() != null) { + throw new AssertionError("Resource-only discovery must not run inside Forge"); + } + // Forge64 removed ModList.of. Seed its native lazy discovery record without claiming + // dependency sorting, access-transformer application, or loading production mod classes. + Class stateType = Class.forName("net.minecraftforge.fml.loading.ModSorter$State"); + var constructor = stateType.getDeclaredConstructor(List.class, List.class); + constructor.setAccessible(true); + Object state = constructor.newInstance(List.of(mod), + mod.getModInfos().stream().map(ModInfo.class::cast).toList()); + Class discovery = Class.forName("net.minecraftforge.fml.loading.LoadingModListImpl"); + var temporaryState = discovery.getDeclaredField("temp"); + temporaryState.setAccessible(true); + if (temporaryState.get(null) != null) throw new AssertionError("Discovery is already initializing"); + temporaryState.set(null, state); + try { + ModList.init(); + } finally { + temporaryState.set(null, null); + } + if (ModList.getMods().size() != 1 || !ModList.getModFiles().equals(List.of(mod.getModFileInfo())) + || !ModList.getMods().equals(mod.getModInfos()) + || !net.minecraftforge.fml.loading.LoadingModList.getErrors().isEmpty() + || !net.minecraftforge.fml.loading.LoadingModList.getBrokenFiles().isEmpty()) { + throw new AssertionError("Native resource-only discovery did not retain the exact parsed mod"); + } + } } diff --git a/src/test/java/zone/moddev/mc/basepower/util/InventoryLocksTest.java b/src/test/java/zone/moddev/mc/basepower/util/InventoryLocksTest.java new file mode 100644 index 00000000..c3f89b8e --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/util/InventoryLocksTest.java @@ -0,0 +1,67 @@ +package zone.moddev.mc.basepower.util; + +import net.minecraft.advancements.criterion.ItemPredicate; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponentMap; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.CompoundContainer; +import net.minecraft.world.LockCode; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class InventoryLocksTest { + @BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + } + + @Test public void ordinaryInventoryNeedsNoVanillaLockAccess() { + assertFalse(InventoryLocks.isLocked(new SimpleContainer(1))); + assertFalse(InventoryLocks.isLocked(null)); + } + + @Test public void unlockedVanillaChestIsAccessible() { + assertFalse(InventoryLocks.isLocked(chest(false))); + } + + @Test public void lockedVanillaChestAndItsWrapperStayLocked() { + ChestBlockEntity chest = chest(true); + assertTrue(chest.isLocked()); + assertTrue(InventoryLocks.isLocked(chest)); + assertTrue(InventoryLocks.isLocked(InventoryWrapper.wrap(chest))); + } + + @Test public void bothUnlockedDoubleChestHalvesAreAccessible() { + assertFalse(InventoryLocks.isLocked(new CompoundContainer(chest(false), chest(false)))); + } + + @Test public void eitherLockedDoubleChestHalfBlocksAutomation() { + assertTrue(InventoryLocks.isLocked(new CompoundContainer(chest(true), chest(false)))); + assertTrue(InventoryLocks.isLocked(new CompoundContainer(chest(false), chest(true)))); + } + + @Test public void nestedContainersCannotHideALockedChest() { + CompoundContainer nested = new CompoundContainer(new SimpleContainer(1), + new CompoundContainer(chest(false), chest(true))); + assertTrue(InventoryLocks.isLocked(nested)); + assertTrue(InventoryLocks.isLocked(InventoryWrapper.wrap(nested))); + } + + private static ChestBlockEntity chest(boolean locked) { + ChestBlockEntity chest = new ChestBlockEntity(BlockPos.ZERO, Blocks.CHEST.defaultBlockState()); + if (locked) { + LockCode code = new LockCode(ItemPredicate.Builder.item() + .of(BuiltInRegistries.ITEM, Items.TRIPWIRE_HOOK).build()); + chest.applyComponents(DataComponentMap.EMPTY, + DataComponentPatch.builder().set(DataComponents.LOCK, code).build()); + } + return chest; + } +} diff --git a/test-rig/README.md b/test-rig/README.md index 5a562498..eb3045c2 100644 --- a/test-rig/README.md +++ b/test-rig/README.md @@ -1,6 +1,231 @@ -# Advantage Works 1.21.1 Test Rig +# Advantage Works Test Rig -The current target is Minecraft 1.21.1, Forge 52.1.0 and Java 21. Start with +## Native 26.1.2: Local And Unqualified + +> **Current-instruction boundary:** Only this native26 section describes the +> current port. Every section from **Retained 1.21.1 Reference** onward is +> historical, not current native26 launch or qualification instructions. +> In particular, the Java8/17 commands and decoupled-without-BaseMetals profiles +> below must not be used for native26. **Base Metals is mandatory for native26**; +> there is no supported native26 decoupled qualification profile. + +The native26 adapters target Minecraft **26.1.2**, Forge **64.1.5**, MCP +**20260409.101008**, and the hash-pinned **Temurin 25.0.3+9** runtime. They are +additive: do not replace historical `native*` drivers, profiles, source readers, +or expectations with a global version substitution. + +**Final qualification profile: PENDING.** Candidate07 is awaiting the retained +OreSpawn profile-migration and client gates. Earlier candidate05/06 machine +and endurance receipts are historical, scoped evidence, not a final family +pass. No filename or hash in this section stands in for the future final +profile. Never retarget an old receipt after rebuilding any input. + +### Strict Inputs And Build + +The server core accepts only an explicitly materialized JSON profile with +`mode: packaged`, `minecraftVersion: 26.1.2`, `forge.version: 26.1.2-64.1.5`, +and `qualificationStatus: EXPLORATORY_UNQUALIFIED`. A future "final" profile +still needs this supported input status; individual `PASS` receipts do not +authorize publication or imply overall acceptance. + +All six ordinary inputs are mandatory. Every `mods` row must provide the exact +`id`, `version`, `file`, `path`, and full SHA-256 of the actual frozen jar: + +| Mod ID | Required version | +| --- | --- | +| `basepower` | `2.4.16.2601021` | +| `basesteam` | `2.2.17.2601021` | +| `baseelectric` | `2.2.17.2601021` | +| `basemetals` | `3.1.2.2601021` | +| `orespawn` | `4.1.1.2601021` | +| `advantageworkstest` | `1.1.6.2601021` | + +Missing, duplicate, unexpected, wrong-version or changed inputs fail closed. +Relative mod paths resolve from `test-rig/`, not the shell's working directory. +Freeze independent, read-only copies outside compiler outputs and caches; do not +point a qualification profile at jars another build can overwrite. +The harness manifest must pin the exact staged ordinary Power jar through +`Power-Input-SHA256`, with the selected `Built-On` and unqualified status. +Build the harness against that actual Power artifact, not a stale deobf jar +or an invented hash. `test-rig/gradle.properties` owns `works_power_sha256`. + +From the repository root, with the selected Java25 installation: + +```powershell +$env:JAVA_HOME = '' +./gradlew.bat -p test-rig check build +if ($LASTEXITCODE -ne 0) { throw 'Harness build/check failed' } +``` + +Compiler outputs remain in `test-rig/build/gradle/`; `clean` is restricted to +that directory. Do not run concurrent Gradle builds against the same project +cache. A build, worldless unit bootstrap, or preflight is not a real-server +qualification. + +Use a separately sealed, fresh **code-only** Forge64 SDK seed: only the selected +shim jar and `libraries/`, with no worlds or user configuration. The core checks +the Java executable, shim, native binary, official launch arguments and full +code inventory. It does not reuse an older-version seed. `native2612.py seal` +creates this SDK inventory; it is **not** the endurance pre-run seal. + +Ordinary qualification launches require a free loopback TCP port, disabled empty-server pausing, +and no inherited `JAVA_TOOL_OPTIONS`, `JDK_JAVA_OPTIONS`, `_JAVA_OPTIONS` or +`CLASSPATH` overrides. An occupied port is an error, never permission to kill +another process. Use an explicit Python executable with `nbtlib` and a reviewed +dependency closure. Endurance custody also pins the interpreter, base executable, +imported NumPy code and native libraries. Keep the same Python/PYTHONPATH closure +for seal, producer, freeze and replay; do not bypass alias or hard-link guards. + +### Native26 CLI + +Run these examples from `test-rig/migration/`. Replace every angle-bracket +placeholder with a verified input or a **new**, disjoint output path. The port +numbers are examples: check ownership and availability immediately before launch. +Stop on any failure and retain its original output. + +```powershell +$Python = '' +$Profile = '' +$ProfileSha = '' +$Seed = '' +$Inventory = '' +$InventorySha = '' +$Java = '/bin/java.exe' +$Inputs = @('--profile', $Profile, '--seed', $Seed, + '--inventory', $Inventory, '--inventory-sha256', $InventorySha, '--java', $Java) + +if ($ProfileSha -notmatch '^[A-Fa-f0-9]{64}$' -or + (Get-FileHash -LiteralPath $Profile -Algorithm SHA256).Hash -ne $ProfileSha) { + throw 'Frozen profile hash differs; do not launch' +} +& $Python -B native2612.py verify @Inputs +if ($LASTEXITCODE -ne 0) { throw 'Native input preflight failed' } +& $Python -B native2612_operations.py @Inputs --group all ` + --output '' --port 25620 +if ($LASTEXITCODE -ne 0) { throw 'Finite operation/restart/raw audit failed' } +``` + +Preflight opens no world. `--group all` requires all 132 finite cases, an actual +cold restart, both stopped raw-storage audits and unchanged seed custody. +Smaller groups are focused checks, not the full finite-operation gate. + +Other native26 entry points have their own required inputs; inspect their +`--help` rather than copying historical runner defaults: + +| Entry point under `migration/` | Separate scope | +| --- | --- | +| `native2612_broad.py`, `native2612_interop.py` | Walkable Works/lifecycle and real cross-mod interoperability | +| `native2612_machines.py`, `native2612_networks.py` | Harvest, movement, physical unload/reload orders and extended drill/network controls | +| `native2612_upgrade.py` | Explicit historical source route, native conversion, operation, restart and raw saved witnesses | +| `native2612_actors.py`, `native2612_actor_transformer_order.py` | Player/entity/dimension authority and actor-inclusive real transformer orders; not replaced by machine-only migration | +| `native2612_resources.py` | Real ingredient acquisition, recipe unlock and manufacturing progression | +| `native2612_worldgen.py`, `native2612_retained_worldgen.py`, `native2612_oil.py` | Normal/retained generation semantics and bounded oil processing/transport, not superflat station coverage | +| `native2612_client.py` | Separate real-client rendering evidence; server or unit results do not replace it | + +Native destination readers use DataVersion 4790, explicit +`dimensions///` terrain/entity storage, `players/data/`, and +the separate worldgen SavedData. Historical source layouts remain version-specific. +Do not infer destination dimensions from a legacy `DIM*` basename or discard +unknown NBT to make a comparison pass. + +### Genuine Endurance, Freeze And Replay + +Before **any** endurance setup, finish the full finite gate and a new full +`original110` machine migration against the same frozen profile. A hook-only, +provisional, old-candidate or machine-only result cannot stand in for a separately +required actor/player campaign. The retained endurance input must be the stopped +runtime and exact successful full `original110` receipt, including operation. + +The reviewed engine receipt is a separate required input. It binds the actual +core/classifier and primary native source/binary proof; `seal` does not manufacture +that review. Keep the complete transitive oracle/runtime closure unchanged. + +```powershell +$EngineReceipt = '' +$EngineSha = '' +$Retained = '' +$RetainedReceipt = '' +$RetainedSha = '' +$Seal = '' +$Run = '' +$Frozen = '' + +& $Python -B native2612_endurance_replay.py seal --profile $Profile ` + --profile-sha256 $ProfileSha --specification '../src/main/resources/assets/advantageworkstest/works/works-1.13.2.json' ` + --inventory $Inventory --inventory-sha256 $InventorySha --java $Java ` + --engine-receipt $EngineReceipt --engine-receipt-sha256 $EngineSha ` + --retained-receipt $RetainedReceipt --retained-receipt-sha256 $RetainedSha --output $Seal +if ($LASTEXITCODE -ne 0) { throw 'Preseal failed; do not begin setup' } +$SealSha = (Get-FileHash -LiteralPath $Seal -Algorithm SHA256).Hash + +& $Python -B native2612_endurance.py @Inputs --seal $Seal --seal-sha256 $SealSha ` + --retained $Retained --retained-receipt $RetainedReceipt ` + --retained-receipt-sha256 $RetainedSha --seconds 1800 --port 25621 --output $Run +if ($LASTEXITCODE -ne 0) { throw 'Endurance incomplete; retain failure or hold receipt' } + +$Receipt = Join-Path $Run 'native2612-endurance-results.json' +$ReceiptSha = (Get-FileHash -LiteralPath $Receipt -Algorithm SHA256).Hash +& $Python -B native2612_endurance_replay.py freeze --run $Run --seal $Seal ` + --seal-sha256 $SealSha --receipt-sha256 $ReceiptSha --output $Frozen +if ($LASTEXITCODE -ne 0) { throw 'Stopped freeze failed' } +$FreezeSha = (Get-FileHash -LiteralPath (Join-Path $Frozen 'freeze.json') -Algorithm SHA256).Hash +& $Python -B native2612_endurance_replay.py replay --frozen $Frozen ` + --freeze-sha256 $FreezeSha --seal-sha256 $SealSha --output '' +if ($LASTEXITCODE -ne 0) { throw 'Independent stopped replay failed' } +``` + +The producer requires two adjacent free ports and runs separate fresh and retained +worlds with independent native clocks. Each needs at least 1,800 measured seconds, +30 natural-tick batches, at least 18 TPS in every measured interval, a full process +restart, and at least 60 seconds of resumed processing. A below-threshold resource +throughput failure remains a failure, not a waived gameplay pass. A +`hold-endurance.request` produces `HELD`, never qualification. + +After final tick-frozen observations and owned save/stop, `freeze` requires stopped +worlds and exact source/copy inventories. Independent replay checks all 66 ledger +checkpoints, every batch, interval clocks, feed conservation and exact typed raw +native4790 machine state/NBT. It launches no Minecraft process and does not prove +client appearance, player conversion, every worldgen rule or manual gameplay. +`--retrospective` is diagnostic only: its replay is explicitly +`PASS_RETROSPECTIVE_REPLAY_NOT_PRE_RUN_CUSTODY`, not a genuine presealed pass. +Record the full profile, seal, producer, freeze and replay hashes plus process/port +closure. Never overwrite a failed attempt or substitute later jars into its proof. + +### Retained Failures And Negative Controls + +Known historical failures are evidence, not runtime ignore rules: + +| Retained observation | Required distinction | +| --- | --- | +| Obsolete InventoryLocks SRG field lookup; nullable Steam fuel remainder; last-fuel container loss | Real production failures with retained RED evidence; new jars need new full input pins and campaigns | +| Saved Steam enchantment IDs flattened as vanilla curses before selected-world context preparation | Machine-only migration/endurance could pass while actor preservation failed; require the independent actor/order scope | +| Global atlas directories importing foreign low-resolution sprites | A dedicated-server pass does not qualify native mip-level client startup or geometry | +| Active legacy OreSpawn profiles retaining obsolete Power/Electric output IDs | Preserved JSON and valid new defaults do not establish equivalent retained generation; final profile-migration qualification remains pending | + +Focused controls reject wrong Java or shim bytes, modified launch arguments, +missing/duplicate mods, harness/Power pin mismatch, inherited JVM overrides, +occupied ports, empty-server pausing, incomplete cold stop and tampered evidence. +A crash can exit 0: exit code alone is insufficient. Native identity and `Done` +must precede an owned stop and the final post-stop dimension-save witness; an +earlier startup/autosave cannot replace the final flush. + +The Forge64 logging adapter changes exactly two source-hash-bound `%xEx` tokens +to `%ex`; it does not disable DEBUG, filters or diagnostics. The strict Netty +classifier recognizes only the reviewed, source/binary-bound Java25 capability +probe sequence followed by successful Java25 cleaner availability. Raw logs are +retained. Changed counts, levels, messages, frames or missing success are errors, +not a blanket exception whitelist or a reason to re-enable Unsafe. + +--- + +> **Historical references below, not native26 instructions.** Their Java versions, +> profiles, optional-mod rules and example commands belong to their stated older +> targets. Native26 requires all six pinned inputs, including Base Metals; use +> only the native26 section above for the current port. + +## Retained 1.21.1 Reference + +The retained target is Minecraft 1.21.1, Forge 52.1.0 and Java 21. Start with the [native test instructions](profiles/native1211/README.md) and the [port record](../docs/1.21.1/PORT-STATUS.md). Use only the current hash-pinned inputs in disposable worlds. Earlier results do not qualify a rebuilt jar. diff --git a/test-rig/build.gradle b/test-rig/build.gradle index 52814e7b..664313f9 100644 --- a/test-rig/build.gradle +++ b/test-rig/build.gradle @@ -5,8 +5,8 @@ plugins { id 'net.minecraftforge.gradle' version '7.0.34' } -ext.forge61MavenizerDefault = '../../../../MinecraftMineralogy/OS12111/OreSpawn/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar' -apply from: '../gradle/forge61-mavenizer.gradle' +ext.forge64MavenizerDefault = '../../../../MinecraftMineralogy/OS2612/OreSpawn/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar' +apply from: '../gradle/forge64-mavenizer.gradle' // Saves, pinned inputs and evidence are siblings, never Gradle cleanup targets. layout.buildDirectory = layout.projectDirectory.dir('build/gradle') @@ -25,15 +25,15 @@ version = project.harness_version base.archivesName = 'AdvantageWorksTestHarness' java { - toolchain.languageVersion = JavaLanguageVersion.of(21) + toolchain.languageVersion = JavaLanguageVersion.of(25) } tasks.withType(JavaCompile).configureEach { javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 options.encoding = 'UTF-8' options.compilerArgs.addAll(['-Xmaxerrs', '1000']) } @@ -70,7 +70,7 @@ repositories { } def powerCompileJar = file(project.findProperty('powerAdvantageReleaseJar') - ?: '../build/libs/basePower-2.4.15.121111.jar') + ?: '../build/libs/basePower-2.4.16.2601021.jar') def exploratoryPowerSha = providers.gradleProperty('worksExploratoryPowerSha256') def powerCompileSha = exploratoryPowerSha.orElse(providers.gradleProperty('works_power_sha256')) @@ -107,7 +107,7 @@ tasks.register('verifyWorksCompileInputPin') { inputs.file(powerCompileJar) inputs.property('powerSha256', powerCompileSha.orElse('')) doLast { - if (!powerCompileJar.isFile()) throw new GradleException("Missing 1.21.11 Power compile input: ${powerCompileJar}") + if (!powerCompileJar.isFile()) throw new GradleException("Missing 26.1.2 Power compile input: ${powerCompileJar}") if (!(powerCompileSha.getOrElse('') ==~ /[A-Fa-f0-9]{64}/)) { throw new GradleException('Missing or invalid Power SHA-256 pin') } @@ -257,6 +257,20 @@ tasks.register('verifyWorksNative12111Inputs', JavaExec) { } } +tasks.register('verifyWorksNative2612Inputs', JavaExec) { + group = 'verification' + description = 'Checks actual 26.1.2 candidate/Java/seed identities without opening a world.' + dependsOn tasks.named('runnerClasses'), tasks.named('jar') + classpath = sourceSets.runner.runtimeClasspath + mainClass = 'zone.moddev.mc.advantageworks.runner.WorksRunner' + doFirst { JavaExec task -> + String pin = providers.gradleProperty('worksProfileFile').orNull + if (!pin) throw new GradleException('Set -PworksProfileFile to a generated Forge 64 profile') + args '--profile', file(pin).absolutePath, '--validate-only' + configurePreparedSeedArgs(task) + } +} + tasks.register('verifyHarnessIsolation') { group = 'verification' description = 'Confirms the PowerAdvantage release contains no test-harness content.' @@ -293,7 +307,7 @@ tasks.register('verifyForge61UnitBootstrap', JavaExec) { dependsOn compileUnitSupport, unitBootstrapJar classpath = files(compileUnitSupport.flatMap { it.destinationDirectory }) + nativeControlClasspath mainClass = 'zone.moddev.mc.advantageworks.unit.Forge61UnitControl' - javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(21) } + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } doFirst { jvmArgs "-javaagent:${unitBootstrapJar.get().archiveFile.get().asFile}" } } def compilePlatformControls = tasks.register('compilePlatformControls', JavaCompile) { @@ -308,7 +322,7 @@ tasks.register('verifyNativeWolfPlatform', JavaExec) { classpath = files(compilePlatformControls.flatMap { it.destinationDirectory }, compileUnitSupport.flatMap { it.destinationDirectory }) + nativeControlClasspath mainClass = 'NativeWolfPlatformControl' - javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(21) } + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } doFirst { def output = providers.gradleProperty('nativeControlOutput').orNull if (!output || file(output).exists()) throw new GradleException('Supply a new -PnativeControlOutput directory') @@ -323,7 +337,7 @@ tasks.register('verifyNativeLeashKnotPlatform', JavaExec) { classpath = files(compilePlatformControls.flatMap { it.destinationDirectory }, compileUnitSupport.flatMap { it.destinationDirectory }) + nativeControlClasspath mainClass = 'NativeLeashKnotPlatformControl' - javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(21) } + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } doFirst { def output = providers.gradleProperty('nativeControlOutput').orNull if (!output || file(output).exists()) throw new GradleException('Supply a new -PnativeControlOutput directory') diff --git a/test-rig/client-resources/atlas_graph.py b/test-rig/client-resources/atlas_graph.py new file mode 100644 index 00000000..0d1add58 --- /dev/null +++ b/test-rig/client-resources/atlas_graph.py @@ -0,0 +1,87 @@ +"""Deterministic native26 atlas singles, derived from model materials and explicit client sprites.""" + +import argparse +import json +from pathlib import Path +import struct + + +POWER = Path(__file__).resolve().parents[2] +ROOTS = { + "basepower": POWER / "src/main/resources", + "basesteam": POWER.parent / "SteamAdvantage/SteamAdvantage/src/main/resources", + "baseelectric": POWER.parent / "ElectricAdvantage/ElectricAdvantage/src/main/resources", +} +MANUAL = { + "basepower": {"blocks/crude_oil_still", "blocks/crude_oil_flow", "blocks/refined_oil_still", "blocks/refined_oil_flow"}, + "basesteam": {"entity/drill_bit"}, + "baseelectric": {"entity/laser_turret"}, +} + + +def graph(roots=ROOTS): + textures = {owner: set() for owner in roots} + for root in roots.values(): + for path in sorted((root / "assets").glob("*/models/**/*.json")): + model = json.loads(path.read_text(encoding="utf-8")) + for material in model.get("textures", {}).values(): + if isinstance(material, dict): + material = material["sprite"] + if not isinstance(material, str) or not material: + raise ValueError("Invalid model material: " + str(path)) + if material.startswith("#"): + continue + namespace, name = material.split(":", 1) if ":" in material else ("minecraft", material) + if namespace in textures: + textures[namespace].add(name) + result = {} + for owner, root in roots.items(): + selected = textures[owner] | MANUAL[owner] + result[owner] = {"blocks": [], "items": []} + for name in sorted(selected): + texture_root = (root / "assets" / owner / "textures").resolve() + path = (texture_root / (name + ".png")).resolve() + if not path.is_relative_to(texture_root) or not path.is_file(): + raise ValueError("Referenced owned texture missing: " + owner + ":" + name) + data = path.read_bytes() + if data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR": + raise ValueError("Invalid source PNG: " + str(path)) + width, height = struct.unpack(">II", data[16:24]) + atlas = "items" if name.startswith(("items/", "item/")) else "blocks" + if atlas == "blocks" and (width < 16 or height < 16): + raise ValueError("Native default mip4 is incompatible with referenced block sprite: " + owner + ":" + name) + result[owner][atlas].append(owner + ":" + name) + return result + + +def document(resources): + return {"sources": [{"type": "minecraft:single", "resource": resource} for resource in resources]} + + +def check(documents, expected): + for owner, atlases in expected.items(): + for atlas, resources in atlases.items(): + actual = documents[owner, atlas] + if actual != document(resources): + raise ValueError("Atlas differs from exact owned material graph: " + owner + "/" + atlas) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true") + args = parser.parse_args() + expected = graph() + documents = {} + for owner, root in ROOTS.items(): + for atlas, resources in expected[owner].items(): + path = root / "assets/minecraft/atlases" / (atlas + ".json") + if args.write: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(document(resources), indent=2) + "\n", encoding="utf-8") + documents[owner, atlas] = json.loads(path.read_text(encoding="utf-8")) + print(owner, atlas, len(resources)) + check(documents, expected) + + +if __name__ == "__main__": + main() diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ChildRendererGeometryProbe.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ChildRendererGeometryProbe.java index 6621fa2b..91018e81 100644 --- a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ChildRendererGeometryProbe.java +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ChildRendererGeometryProbe.java @@ -51,7 +51,8 @@ static void verify(Minecraft minecraft, JsonObject result) { result.add("child_renderer_geometry", report); try { require(Boolean.getBoolean("advantageworks.requireExtensions"), "Child renderer geometry requires the combined profile"); - require(minecraft.level == null && minecraft.player == null && !minecraft.hasSingleplayerServer(), "Geometry probe must remain worldless"); + require(minecraft.level == null && minecraft.player == null && minecraft.getSingleplayerServer() == null + && !minecraft.hasSingleplayerServer(), "Geometry probe must remain genuinely worldless"); steam(minecraft, cases); drill(minecraft, cases); turret(minecraft, cases); @@ -206,7 +207,7 @@ private static List render(Minecraft minecraft, BlockEntity tile, Strin geometry.render(submitted.last(), buffers.getBuffer(pipeline)); return null; }); - renderer.submit(state, matrices, collector, new net.minecraft.client.renderer.state.CameraRenderState()); + renderer.submit(state, matrices, collector, new net.minecraft.client.renderer.state.level.CameraRenderState()); } catch (RuntimeException | LinkageError | AssertionError failure) { renderFailure = failure; row.addProperty("render_error", failure.toString()); @@ -243,7 +244,7 @@ private static final class Capture implements MultiBufferSource { } @Override public VertexConsumer getBuffer(RenderType type) { - require(type.mode() == com.mojang.blaze3d.vertex.VertexFormat.Mode.QUADS && type.format().equals(DefaultVertexFormat.NEW_ENTITY), "Unexpected production renderer vertex format/mode"); + require(type.mode() == com.mojang.blaze3d.vertex.VertexFormat.Mode.QUADS && type.format().equals(DefaultVertexFormat.ENTITY), "Unexpected production renderer vertex format/mode"); BufferBuilder buffer = buffers.get(type); if (buffer == null) { require(buffers.size() < 8, "Renderer requested too many buffers"); @@ -270,10 +271,10 @@ void finish() throws Exception { require(count <= 4096, "Renderer exceeded the bounded geometry fixture"); VertexFormat format = built.drawState().format(); ByteBuffer data = built.vertexBuffer(); - int p = offset(format, VertexFormatElement.Usage.POSITION, 0); - int uv = offset(format, VertexFormatElement.Usage.UV, 0); - int n = offset(format, VertexFormatElement.Usage.NORMAL, 0); - int c = offset(format, VertexFormatElement.Usage.COLOR, 0); + int p = offset(format, VertexFormatElement.POSITION); + int uv = offset(format, VertexFormatElement.UV0); + int n = offset(format, VertexFormatElement.NORMAL); + int c = offset(format, VertexFormatElement.COLOR); for (int i = 0; i < count; i++) { int base = i * format.getVertexSize(); float[] vertex = {data.getFloat(base + p), data.getFloat(base + p + 4), data.getFloat(base + p + 8), @@ -369,12 +370,12 @@ private static void set(Object object, String name, Object value) throws Reflect throw new NoSuchFieldException(object.getClass().getName() + "." + name); } - private static int offset(VertexFormat format, VertexFormatElement.Usage usage, int index) { + private static int offset(VertexFormat format, VertexFormatElement required) { for (int i = 0; i < format.getElements().size(); i++) { VertexFormatElement element = format.getElements().get(i); - if (element.usage() == usage && element.index() == index) return format.getOffset(i); + if (element.equals(required)) return format.getOffset(i); } - throw new IllegalStateException("Native vertex format lacks " + usage + " " + index); + throw new IllegalStateException("Native vertex format lacks " + required); } private static boolean different(List a, List b) { diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java index a496e253..bfe83387 100644 --- a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java @@ -4,8 +4,9 @@ import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.client.Minecraft; -import net.minecraft.client.renderer.block.model.BakedQuad; -import net.minecraft.client.renderer.block.model.BlockStateModel; +import net.minecraft.client.resources.model.geometry.BakedQuad; +import net.minecraft.client.renderer.block.dispatch.BlockStateModel; +import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart; import net.minecraft.client.renderer.item.ItemStackRenderState; import net.minecraft.client.resources.language.I18n; import net.minecraft.world.item.Item; @@ -25,42 +26,120 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import net.minecraft.util.RandomSource; -/** Runs after native resource baking, without opening or generating a world. */ +/** Runs after real native login and resource baking in a disposable copied world. */ @Mod(ClientResourceProbe.MODID) public final class ClientResourceProbe { static final String MODID = "advantageclientresources"; private static boolean finished; private static int ticks; + private static String lastReadiness = ""; + private static JsonObject lastReadinessState; + private static final NativeExperimentalFixtureGate FIXTURE_GATE = new NativeExperimentalFixtureGate(); + private static JsonObject preLoginGeometry; + private static net.minecraft.client.gui.screens.Screen disconnectedScreen; + private static int disconnectedTick; + private static NativeWorldOilProbe worldOil; + private static JsonObject worldOilReport; + private static boolean worldOilComplete; + private static net.minecraft.network.Connection loggedInConnection; public ClientResourceProbe() { + NativeVanillaResourceProbe.register(); + net.minecraftforge.client.event.ClientPlayerNetworkEvent.LoggingIn.BUS.addListener( + event -> loggedInConnection = event.getConnection()); TickEvent.ClientTickEvent.Post.BUS.addListener(ClientResourceProbe::tick); } public static void tick(TickEvent.ClientTickEvent.Post event) { if (!Boolean.getBoolean("advantageworks.clientResources") || finished) return; Minecraft minecraft = Minecraft.getInstance(); - if (++ticks < 5 || ((minecraft.getOverlay() != null || minecraft.getModelManager().getMissingBlockStateModel() == null) && ticks < 1200)) return; + boolean loggedIn = minecraft.level != null && minecraft.player != null + && minecraft.getConnection() != null && minecraft.hasSingleplayerServer() + && minecraft.getConnection().getConnection() == loggedInConnection + && loggedInConnection != null && loggedInConnection.isConnected() + && minecraft.getConnection().hasClientLoaded(); + boolean componentsBound = loggedIn && net.minecraft.core.registries.BuiltInRegistries.ITEM.listElements() + .allMatch(net.minecraft.core.Holder.Reference::areComponentsBound); + ++ticks; + Throwable readinessFailure = null; + try { + recordReadiness(minecraft, loggedIn, componentsBound); + failDisconnected(minecraft); + if (Boolean.getBoolean("advantageworks.childRendererGeometry") && preLoginGeometry == null + && minecraft.level == null && minecraft.player == null && minecraft.getSingleplayerServer() == null + && minecraft.getOverlay() == null && minecraft.getModelManager().getBlockStateModelSet() != null) { + preLoginGeometry = new JsonObject(); + inspect("child_geometry", preLoginGeometry, () -> ChildRendererGeometryProbe.verify(minecraft, preLoginGeometry)); + JsonObject context = new JsonObject(); + context.addProperty("stage", "GENUINELY_WORLDLESS_BEFORE_LOGIN"); + context.addProperty("tick", ticks); + context.addProperty("levelPresent", minecraft.level != null); + context.addProperty("playerPresent", minecraft.player != null); + context.addProperty("singleplayerServerPresent", minecraft.getSingleplayerServer() != null); + preLoginGeometry.add("child_geometry_context", context); + } + FIXTURE_GATE.tick(minecraft, ticks, lastReadinessState); + } catch (RuntimeException | LinkageError | AssertionError failure) { readinessFailure = failure; } + if (readinessFailure == null && (ticks < 5 || ((!componentsBound || minecraft.getOverlay() != null + || minecraft.getModelManager().getBlockStateModelSet() == null) && ticks < 2400))) return; + if (readinessFailure == null && loggedIn && componentsBound && !worldOilComplete) { + if (worldOil == null) worldOil = new NativeWorldOilProbe(); + try { + worldOilReport = worldOil.tick(minecraft, ticks); + if (worldOilReport == null) return; + } catch (RuntimeException | LinkageError | AssertionError failure) { + worldOilReport = worldOil.report(); + LogManager.getLogger(MODID).error("ADVANTAGE_CLIENT_WORLD_OIL FAIL", failure); + } + worldOilComplete = true; + } finished = true; JsonObject result = new JsonObject(); try { result.addProperty("minecraft", net.minecraftforge.versions.mcp.MCPVersion.getMCVersion()); result.addProperty("forge", net.minecraftforge.versions.forge.ForgeVersion.getVersion()); - require("1.21.11".equals(result.get("minecraft").getAsString()) - && "61.1.0".equals(result.get("forge").getAsString()), "Wrong native client target"); - require(minecraft.level == null && minecraft.player == null && !minecraft.hasSingleplayerServer(), - "Resource probe must not open a world or connect to a server"); + if (lastReadinessState != null) result.add("loginReadiness", lastReadinessState.deepCopy()); + result.add("experimentalFixtureGate", FIXTURE_GATE.report()); + if (preLoginGeometry != null) for (var row : preLoginGeometry.entrySet()) result.add(row.getKey(), row.getValue().deepCopy()); + if (worldOilReport != null) result.add("world_oil_visual", worldOilReport.deepCopy()); + if (readinessFailure != null) throw new IllegalStateException("Native login/fixture gate failed", readinessFailure); + require("26.1.2".equals(result.get("minecraft").getAsString()) + && "64.1.5".equals(result.get("forge").getAsString()) + && Runtime.version().feature() == 25, "Wrong native client target"); + require(loggedIn && componentsBound, "Native real-world login/component binding did not complete"); + var expectedWorld = Path.of("saves/AdvantageClientResources").toRealPath(); + var actualWorld = minecraft.getSingleplayerServer() + .getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toRealPath(); + require(actualWorld.equals(expectedWorld), "Integrated server opened a world other than the disposable copy"); + JsonObject login = new JsonObject(); + login.addProperty("status", "PASS"); + login.addProperty("realWorld", true); + login.addProperty("integratedServer", true); + login.addProperty("nativeComponentsBound", true); + login.addProperty("syntheticComponentBinding", false); + login.addProperty("loggingInEventObserved", true); + login.addProperty("connectionConnected", true); + login.addProperty("clientLoaded", true); + login.addProperty("copiedWorldPath", actualWorld.toString()); + login.addProperty("itemHolders", net.minecraft.core.registries.BuiltInRegistries.ITEM.listElements().count()); + login.addProperty("dimension", minecraft.level.dimension().identifier().toString()); + result.add("login", login); + JsonObject calibration = NativeGuiProjection.calibrate(minecraft); + require("PASS".equals(calibration.get("status").getAsString()), "Native projection calibration failed"); + result.add("projectionCalibration", calibration); verifyNetworkBootstrap(result); com.google.gson.JsonArray mods = new com.google.gson.JsonArray(); JsonObject versions = new JsonObject(); for (String id : new String[]{"basepower", "basesteam", "baseelectric"}) { - boolean loaded = net.minecraftforge.fml.ModList.get().isLoaded(id); + boolean loaded = net.minecraftforge.fml.ModList.isLoaded(id); if (loaded) { mods.add(id); - versions.addProperty(id, net.minecraftforge.fml.ModList.get().getModContainerById(id).get() + versions.addProperty(id, net.minecraftforge.fml.ModList.getModContainerById(id).orElseThrow() .getModInfo().getVersion().toString()); } if (Boolean.getBoolean("advantageworks.requireExtensions")) require(loaded, "Combined launch omitted " + id); @@ -69,11 +148,11 @@ public static void tick(TickEvent.ClientTickEvent.Post event) { result.add("mod_versions", versions); require(versions.has("basepower") && zone.moddev.mc.basepower.PowerAdvantage.VERSION .equals(versions.get("basepower").getAsString()), "Power metadata does not match the compiled version"); - require("2.4.15.121111".equals(versions.get("basepower").getAsString()), "Wrong native Power candidate"); + require("2.4.16.2601021".equals(versions.get("basepower").getAsString()), "Wrong native Power candidate"); for (String id : new String[]{"basesteam", "baseelectric"}) { - if (versions.has(id)) require("2.2.16.121111".equals(versions.get(id).getAsString()), "Wrong native expansion candidate: " + id); + if (versions.has(id)) require("2.2.17.2601021".equals(versions.get(id).getAsString()), "Wrong native expansion candidate: " + id); } - require(minecraft.getModelManager().getMissingBlockStateModel() != null, "Timed out before model baking"); + require(minecraft.getModelManager().getBlockStateModelSet() != null, "Timed out before model baking"); require(minecraft.getOverlay() == null, "Timed out before native resource loading completed"); verifyVanillaParticleControl(minecraft, result); boolean primary = inspect("registered_resources", result, () -> { @@ -84,7 +163,7 @@ public static void tick(TickEvent.ClientTickEvent.Post event) { var hidden = java.util.Set.of("basepower:fluid_pipe_terminal", "basepower:crude_oil", "basepower:refined_oil", "basesteam:pump_pipe_steam", "basesteam:drillbit", "baseelectric:pump_pipe_electric", "baseelectric:laser_turret_evil"); - BlockStateModel missing = minecraft.getModelManager().getMissingBlockStateModel(); + BlockStateModel missing = minecraft.getModelManager().getBlockStateModelSet().missingModel(); for (Item item : ForgeRegistries.ITEMS) { Identifier id = ForgeRegistries.ITEMS.getKey(item); if (id == null || !included(id.getNamespace())) continue; @@ -100,7 +179,7 @@ public static void tick(TickEvent.ClientTickEvent.Post event) { Identifier id = ForgeRegistries.BLOCKS.getKey(block); if (id == null || !included(id.getNamespace())) continue; for (BlockState state : block.getStateDefinition().getPossibleStates()) { - BlockStateModel model = minecraft.getModelManager().getBlockModelShaper().getBlockModel(state); + BlockStateModel model = minecraft.getModelManager().getBlockStateModelSet().get(state); capture("block-model/" + state, resourceFailures, () -> verifyModel(model, missing, state, state.toString(), !(block instanceof net.minecraft.world.level.block.LiquidBlock))); states++; @@ -108,8 +187,8 @@ public static void tick(TickEvent.ClientTickEvent.Post event) { } CreativeModeTab tab = ItemGroups.tab_powerAdvantage; require(tab != null, "The shared creative tab was not registered"); - tab.buildContents(new CreativeModeTab.ItemDisplayParameters(FeatureFlags.DEFAULT_FLAGS, false, - RegistryAccess.EMPTY)); + tab.buildContents(new CreativeModeTab.ItemDisplayParameters(minecraft.level.enabledFeatures(), false, + minecraft.level.registryAccess())); var contents = tab.getDisplayItems(); require(items > 0 && states > 0, "Power Advantage content is not registered"); require(!contents.isEmpty(), "Power Advantage creative tab is empty"); @@ -145,13 +224,17 @@ public static void tick(TickEvent.ClientTickEvent.Post event) { result.addProperty("primary_resource_status", primary ? "PASS" : "FAIL"); boolean geometry = true; if (Boolean.getBoolean("advantageworks.childRendererGeometry")) - geometry = inspect("child_geometry", result, () -> ChildRendererGeometryProbe.verify(minecraft, result)); - require(primary && geometry, "One or more native client probe scopes failed; inspect scope_results"); + geometry = preLoginGeometry != null && "PASS".equals(preLoginGeometry.getAsJsonObject("scope_results") + .getAsJsonObject("child_geometry").get("status").getAsString()); + require(primary && geometry && worldOilComplete && worldOilReport != null + && "PASS".equals(worldOilReport.get("status").getAsString()), + "One or more native client probe scopes failed; inspect scope_results/world_oil_visual"); result.addProperty("status", "PASS"); result.addProperty("screen", minecraft.screen == null ? "none" : minecraft.screen.getClass().getName()); - } catch (RuntimeException | LinkageError | AssertionError failure) { + } catch (IOException | RuntimeException | LinkageError | AssertionError failure) { result.addProperty("status", "FAIL"); result.addProperty("error", failure.toString()); + if (failure.getCause() != null) result.addProperty("cause", failure.getCause().toString()); LogManager.getLogger(MODID).error("ADVANTAGE_CLIENT_RESOURCES FAIL", failure); } finally { try { Files.write(Paths.get("client-resources.json"), result.toString().getBytes(StandardCharsets.UTF_8)); } @@ -160,6 +243,72 @@ public static void tick(TickEvent.ClientTickEvent.Post event) { } } + private static void failDisconnected(Minecraft minecraft) { + if (minecraft.screen == null || minecraft.screen.getClass() != net.minecraft.client.gui.screens.DisconnectedScreen.class + || minecraft.getOverlay() != null) return; + if (minecraft.screen != disconnectedScreen) { disconnectedScreen = minecraft.screen; disconnectedTick = ticks; return; } + if (ticks - disconnectedTick < 20) return; + try { + lastReadinessState.addProperty("nativeDisconnectNarration", minecraft.screen.getNarrationMessage().getString()); + captureReadinessScreen(minecraft, lastReadinessState, Path.of("login-readiness/disconnected.png")); + Files.writeString(Path.of("login-readiness/disconnected.json"), lastReadinessState.toString(), java.nio.file.StandardOpenOption.CREATE_NEW); + } catch (IOException failure) { throw new IllegalStateException("Could not retain native disconnect failure", failure); } + throw new IllegalStateException("Native client disconnected: " + minecraft.screen.getNarrationMessage().getString()); + } + + private static void captureReadinessScreen(Minecraft minecraft, JsonObject state, Path screenshot) throws IOException { + var image = NativeGuiProjection.read(minecraft.getMainRenderTarget()); + try (var output = Files.newOutputStream(screenshot, java.nio.file.StandardOpenOption.CREATE_NEW)) { + require(javax.imageio.ImageIO.write(image, "png", output), "No diagnostic PNG writer"); + } + JsonObject proof = new JsonObject(); + proof.addProperty("file", screenshot.toString().replace('\\', '/')); + try { + proof.addProperty("sha256", java.util.HexFormat.of().withUpperCase().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(screenshot)))); + } catch (java.security.NoSuchAlgorithmException failure) { throw new IllegalStateException(failure); } + proof.addProperty("width", image.getWidth()); proof.addProperty("height", image.getHeight()); + proof.addProperty("fenceCompleted", true); + proof.addProperty("scope", "NATIVE_LOAD_SCREEN_DIAGNOSTIC_NOT_GUI_QUALIFICATION"); + state.add("screenshot", proof); + } + + private static void recordReadiness(Minecraft minecraft, boolean loggedIn, boolean componentsBound) { + JsonObject state = new JsonObject(); + state.addProperty("screenClass", minecraft.screen == null ? "none" : minecraft.screen.getClass().getName()); + state.addProperty("screenTitle", minecraft.screen == null ? "" : minecraft.screen.getTitle().getString()); + state.addProperty("screenTitleComponent", minecraft.screen == null ? "" : minecraft.screen.getTitle().toString()); + state.addProperty("overlayClass", minecraft.getOverlay() == null ? "none" : minecraft.getOverlay().getClass().getName()); + state.addProperty("levelPresent", minecraft.level != null); + state.addProperty("playerPresent", minecraft.player != null); + state.addProperty("integratedServer", minecraft.hasSingleplayerServer()); + state.addProperty("singleplayerServerPresent", minecraft.getSingleplayerServer() != null); + state.addProperty("connectionPresent", minecraft.getConnection() != null); + state.addProperty("loggingInEventObserved", loggedInConnection != null); + state.addProperty("connectionConnected", minecraft.getConnection() != null && minecraft.getConnection().getConnection().isConnected()); + state.addProperty("clientLoaded", minecraft.getConnection() != null && minecraft.getConnection().hasClientLoaded()); + state.addProperty("nativeComponentsBound", componentsBound); + state.addProperty("stage", !minecraft.hasSingleplayerServer() ? "BEFORE_INTEGRATED_SERVER" + : !loggedIn ? "WAITING_NATIVE_LOGIN" : !componentsBound ? "WAITING_COMPONENT_BINDING" : "NATIVE_LOGIN_READY"); + String signature = state.toString(); + lastReadinessState = state; + if (signature.equals(lastReadiness) && ticks % 300 != 0) return; + lastReadiness = signature; + state.addProperty("tick", ticks); + try { + Path folder = Path.of("login-readiness"); + Files.createDirectories(folder); + if (!loggedIn && ticks >= 300 && ticks % 300 == 0) { + Path screenshot = folder.resolve("screen-" + ticks + ".png"); + captureReadinessScreen(minecraft, state, screenshot); + } + Files.writeString(folder.resolve("state-" + ticks + ".json"), state.toString(), java.nio.file.StandardOpenOption.CREATE_NEW); + LogManager.getLogger(MODID).info("ADVANTAGE_CLIENT_READINESS {}", state); + } catch (IOException failure) { + throw new IllegalStateException("Could not retain actual native login readiness", failure); + } + } + private static boolean inspect(String name, JsonObject report, Runnable check) { if (!report.has("scope_results")) report.add("scope_results", new JsonObject()); JsonObject result = new JsonObject(); @@ -205,9 +354,11 @@ private static void verifyItem(Minecraft minecraft, ItemStack stack, String id) Identifier modelId = stack.get(net.minecraft.core.component.DataComponents.ITEM_MODEL); require(modelId != null && minecraft.getModelManager().getItemModels().containsKey(modelId), "Missing native item definition: " + id); ItemStackRenderState state = new ItemStackRenderState(); - minecraft.getItemModelResolver().updateForTopItem(state, stack, net.minecraft.world.item.ItemDisplayContext.GUI, null, null, 0); + minecraft.getItemModelResolver().updateForTopItem(state, stack, net.minecraft.world.item.ItemDisplayContext.GUI, + minecraft.level, minecraft.player, 0); require(!state.isEmpty(), "Empty native item model: " + id); - require(!"missingno".equals(state.pickParticleIcon(RandomSource.create(42L)).contents().name().getPath()), "Missing particle texture: " + id); + var particle = state.pickParticleMaterial(RandomSource.create(42L)); + require(particle != null && !"missingno".equals(particle.sprite().contents().name().getPath()), "Missing particle texture: " + id); try { var countField = ItemStackRenderState.class.getDeclaredField("activeLayerCount"); var layersField = ItemStackRenderState.class.getDeclaredField("layers"); @@ -217,7 +368,7 @@ private static void verifyItem(Minecraft minecraft, ItemStack stack, String id) int quads = 0; for (int i = 0; i < countField.getInt(state); i++) { for (BakedQuad quad : layers[i].prepareQuadList()) { - require(!"missingno".equals(quad.sprite().contents().name().getPath()), "Missing face texture: " + id); + require(!"missingno".equals(quad.materialInfo().sprite().contents().name().getPath()), "Missing face texture: " + id); quads++; } } @@ -229,26 +380,28 @@ private static void verifyItem(Minecraft minecraft, ItemStack stack, String id) private static void verifyModel(BlockStateModel model, BlockStateModel missing, BlockState state, String id, boolean requireQuads) { require(model != missing, "Missing baked model: " + id); - require(!"missingno".equals(model.particleIcon(net.minecraftforge.client.model.data.ModelData.EMPTY) - .contents().name().getPath()), "Missing particle texture: " + id); + require(!"missingno".equals(model.particleMaterial(net.minecraftforge.client.model.data.ModelData.EMPTY) + .sprite().contents().name().getPath()), "Missing particle texture: " + id); RandomSource random = RandomSource.create(42L); List quads = new ArrayList<>(); - for (var part : model.collectParts(random, net.minecraftforge.client.model.data.ModelData.EMPTY, null)) { - require(!"missingno".equals(part.particleIcon().contents().name().getPath()), "Missing part texture: " + id); + List parts = new ArrayList<>(); + model.collectParts(random, parts, net.minecraftforge.client.model.data.ModelData.EMPTY); + for (var part : parts) { + require(!"missingno".equals(part.particleMaterial().sprite().contents().name().getPath()), "Missing part texture: " + id); quads.addAll(part.getQuads(null)); for (Direction side : Direction.values()) quads.addAll(part.getQuads(side)); } require(!requireQuads || !quads.isEmpty(), "Empty baked model: " + id); - for (BakedQuad quad : quads) require(!"missingno".equals(quad.sprite().contents().name().getPath()), "Missing face texture: " + id); + for (BakedQuad quad : quads) require(!"missingno".equals(quad.materialInfo().sprite().contents().name().getPath()), "Missing face texture: " + id); } private static void verifyVanillaParticleControl(Minecraft minecraft, JsonObject report) { var state = net.minecraft.world.level.block.Blocks.STONE.defaultBlockState(); - var shaper = minecraft.getModelManager().getBlockModelShaper(); - var model = shaper.getBlockModel(state); - var legacy = model.particleIcon(); - var nativeIcon = model.particleIcon(net.minecraftforge.client.model.data.ModelData.EMPTY); - var shaperIcon = shaper.getParticleIcon(state); + var models = minecraft.getModelManager().getBlockStateModelSet(); + var model = models.get(state); + var legacy = model.particleMaterial().sprite(); + var nativeIcon = model.particleMaterial(net.minecraftforge.client.model.data.ModelData.EMPTY).sprite(); + var shaperIcon = models.getParticleMaterial(state).sprite(); JsonObject control = new JsonObject(); report.add("vanilla_stone_particle_control", control); control.addProperty("model_class", model.getClass().getName()); @@ -258,7 +411,7 @@ private static void verifyVanillaParticleControl(Minecraft minecraft, JsonObject Identifier expected = Identifier.parse("minecraft:block/stone"); require(nativeIcon.contents().name().equals(expected) && shaperIcon.contents().name().equals(expected), "Native vanilla STONE particle control failed: " + control); - verifyModel(model, minecraft.getModelManager().getMissingBlockStateModel(), state, "vanilla STONE control", true); + verifyModel(model, models.missingModel(), state, "vanilla STONE control", true); control.addProperty("status", "PASS"); } diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java index 9e958f09..a3bf99a7 100644 --- a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java @@ -22,7 +22,7 @@ import javax.imageio.ImageIO; import net.minecraft.world.level.block.Block; import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.Font; import net.minecraft.world.inventory.tooltip.TooltipComponent; import net.minecraft.client.player.LocalPlayer; @@ -45,6 +45,7 @@ static void verify(Minecraft minecraft, JsonObject result) { JsonArray screens = new JsonArray(); result.add("machine_gui_layouts", screens); result.addProperty("machine_gui_text_spec", 2); + result.addProperty("machine_gui_framebuffer_spec", 3); int textChecks = 0, negativeControls = 0; List problems = new ArrayList<>(); LocalPlayer originalPlayer = minecraft.player; @@ -55,20 +56,13 @@ static void verify(Minecraft minecraft, JsonObject result) { expectations = new JsonParser().parse(new InputStreamReader(stream, StandardCharsets.UTF_8)).getAsJsonObject(); } require("Inventory".equals(net.minecraft.client.resources.language.I18n.get("container.inventory")), "GUI label audit requires en_us"); - // ContainerScreen needs a carried-item inventory, but no world or connection. + require(originalPlayer != null && minecraft.level != null, "Machine GUI requires actual native login"); + // Inventory contents are isolated; the owner retains its real connection and registry context. Class allocator = Class.forName("sun.misc.Unsafe"); Field singleton = allocator.getDeclaredField("theUnsafe"); singleton.setAccessible(true); - LocalPlayer player = (LocalPlayer) allocator.getMethod("allocateInstance", Class.class) - .invoke(singleton.get(null), LocalPlayer.class); + LocalPlayer player = originalPlayer; Inventory inventory = new Inventory(player, new net.minecraft.world.entity.EntityEquipment()); - Field inventoryField = Player.class.getDeclaredField("inventory"); - inventoryField.setAccessible(true); - inventoryField.set(player, inventory); - Field cooldowns = Player.class.getDeclaredField("cooldowns"); - cooldowns.setAccessible(true); - cooldowns.set(player, new net.minecraft.world.item.ItemCooldowns()); - minecraft.player = player; Field definitionsField = MachineGUIRegistry.class.getDeclaredField("DEFINITIONS"); definitionsField.setAccessible(true); Map definitions = (Map) definitionsField.get(null); @@ -102,12 +96,10 @@ static void verify(Minecraft minecraft, JsonObject result) { GuiWorld world = (GuiWorld) allocator.getMethod("allocateInstance", Class.class) .invoke(singleton.get(null), GuiWorld.class); world.state = block.defaultBlockState(); + world.registries = minecraft.level.registryAccess(); Field clientSide = net.minecraft.world.level.Level.class.getDeclaredField("isClientSide"); clientSide.setAccessible(true); clientSide.setBoolean(world, true); - Field playerLevel = net.minecraft.world.entity.Entity.class.getDeclaredField("level"); - playerLevel.setAccessible(true); - playerLevel.set(player, world); tile.setLevel(world); SimpleMachineGUI.Container menu = ((SimpleMachineGUI) definition).createMenu(1, inventory, (Container) tile); require(expectations.getAsJsonObject("machines").has(id), "No human-facing label expectation for " + id); @@ -121,6 +113,8 @@ static void verify(Minecraft minecraft, JsonObject result) { row.addProperty("expected_title", expectedTitle); row.addProperty("menu_title", title.getString()); row.addProperty("slots", menu.slots.size()); + JsonArray frameProofs = new JsonArray(); + row.add("framebuffer_cases", frameProofs); int renders = 0; for (int[] viewport : VIEWPORTS) { screen.init(viewport[0], viewport[1]); @@ -147,6 +141,17 @@ static void verify(Minecraft minecraft, JsonObject result) { screen.labels = LabelMode.PRODUCTION; String labelError = labelError(actual, expected, background, screen, viewport[2]); require(labelError == null, labelError); + JsonObject proof = new JsonObject(); + proof.add("actual", NativeGuiEvidence.frame(actual)); + proof.add("expected", NativeGuiEvidence.frame(expected)); + proof.add("background", NativeGuiEvidence.frame(background)); + JsonArray viewportProof = new JsonArray(); + for (int value : viewport) viewportProof.add(value); + proof.add("viewport", viewportProof); + proof.addProperty("fill", fill); + proof.addProperty("left", screen.getLeft()); proof.addProperty("top", screen.getTop()); + proof.add("slot_pixels", verifySlotPixels(minecraft, actual, screen, viewport, texture)); + frameProofs.add(proof); textChecks++; if (fill == 0 && viewport == VIEWPORTS[0]) verifyNoTextOverArtwork(actual, background, screen); require(title.getString().equals(expectedTitle), "Menu title differs from the equipment name: " + title.getString()); @@ -157,6 +162,7 @@ static void verify(Minecraft minecraft, JsonObject result) { negativeControls++; } } + row.add("garbled_background_control", verifyGarbledBackgroundControl(minecraft, screen, texture)); inventory.setItem(0, new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND, 2)); screen.init(320, 240); BufferedImage tooltip = render(minecraft, screen, VIEWPORTS[0], false, true); @@ -170,9 +176,11 @@ static void verify(Minecraft minecraft, JsonObject result) { } if (block == zone.moddev.mc.basepower.init.Blocks.metal_storage_tank) { verifyHelperLimits(minecraft, screen); + negativeControls += verifyBackgroundControl(minecraft, screen); } row.addProperty("renders", renders); row.addProperty("tooltip_and_inventory_hitbox", "PASS"); + row.addProperty("background_extraction_calls", 1); row.addProperty("required_text", "PASS"); row.addProperty("blank_label_controls_rejected", 9); row.addProperty("status", "PASS"); @@ -201,11 +209,17 @@ static void verify(Minecraft minecraft, JsonObject result) { } } - private static void verifySlots(SimpleMachineGUI.GUIContainer screen, SimpleMachineGUI.Container menu) { + private static void verifySlots(RecordingScreen screen, SimpleMachineGUI.Container menu) { require(screen.getLeft() >= 0 && screen.getTop() >= 0 && screen.getLeft() + screen.getXSize() <= screen.width && screen.getTop() + screen.getYSize() <= screen.height, "Screen is clipped at a supported GUI size"); require(menu.slots.size() >= 36, "Missing player inventory"); + require(menu.slots.size() == screen.expectedMachineSlots.size() + 36, "Machine slot count differs from GUI declaration"); + for (int i = 0; i < screen.expectedMachineSlots.size(); i++) { + int[] expected = screen.expectedMachineSlots.get(i); + require(menu.slots.get(i).x == expected[0] && menu.slots.get(i).y == expected[1], + "Machine slot differs from independent GUI declaration: " + i); + } for (int i = 0; i < menu.slots.size(); i++) { Slot slot = menu.slots.get(i); require(slot.x >= 0 && slot.y >= 0 && slot.x + 16 <= screen.getXSize() @@ -244,19 +258,120 @@ private static BufferedImage render(Minecraft minecraft, RecordingScreen screen, private static BufferedImage render(Minecraft minecraft, RecordingScreen screen, int[] viewport, boolean backgroundOnly, boolean hover) throws Exception { int width = viewport[0] * viewport[2], height = viewport[1] * viewport[2]; - return NativeGuiProjection.render(minecraft, width, height, viewport[2], 0xFF1E2D3A, + BufferedImage image = NativeGuiProjection.render(minecraft, width, height, viewport[2], 0xFF1E2D3A, state -> screen.recordingGraphics(minecraft, state), graphics -> { + screen.beginFrame(); + if (screen.duplicateBackground) screen.extractBackground(graphics, -100, -100, 0F); if (backgroundOnly) { LabelMode original = screen.labels; screen.labels = LabelMode.HIDDEN; - try { screen.render(graphics, -100, -100, 0F); } + try { screen.extractRenderStateWithTooltipAndSubtitles(graphics, -100, -100, 0F); } finally { screen.labels = original; } } else { Slot hotbar = screen.getMenu().slots.get(screen.getMenu().slots.size() - 9); - screen.render(graphics, screen.hoverTitle ? screen.width / 2 : hover ? screen.getLeft() + hotbar.x + 1 : -100, + screen.extractRenderStateWithTooltipAndSubtitles(graphics, screen.hoverTitle ? screen.width / 2 : hover ? screen.getLeft() + hotbar.x + 1 : -100, screen.hoverTitle ? screen.getTop() + 4 : hover ? screen.getTop() + hotbar.y + 1 : -100, 0F); } }); + if (screen.duplicateBackground) ImageIO.write(image, "png", new File("machine-gui-layouts/double-background-control.png")); + require(screen.backgroundCalls == 1, "Native screen background must be extracted exactly once, got " + screen.backgroundCalls); + return image; + } + + private static int verifyBackgroundControl(Minecraft minecraft, RecordingScreen screen) throws Exception { + screen.duplicateBackground = true; + try { + boolean rejected = false; + try { render(minecraft, screen, VIEWPORTS[0], false); } + catch (IllegalStateException expected) { + if (!expected.getMessage().startsWith("Native screen background must be extracted exactly once")) throw expected; + rejected = true; + } + require(rejected, "Duplicate native background extraction was not rejected"); + return 1; + } finally { + screen.duplicateBackground = false; + } + } + + private static JsonObject verifyGarbledBackgroundControl(Minecraft minecraft, RecordingScreen screen, + net.minecraft.resources.Identifier texture) throws Exception { + screen.init(VIEWPORTS[0][0], VIEWPORTS[0][1]); + screen.garbleBackground = true; + try { + BufferedImage broken = render(minecraft, screen, VIEWPORTS[0], false); + boolean rejected = false; + try { verifySlotPixels(minecraft, broken, screen, VIEWPORTS[0], texture); } + catch (IllegalStateException expected) { + if (!expected.getMessage().startsWith("Slot framebuffer pixels differ")) throw expected; + rejected = true; + } + require(rejected, "Garbled native background was accepted by slot pixel checks"); + return NativeGuiEvidence.frame(broken); + } finally { + screen.garbleBackground = false; + } + } + + private static JsonObject verifySlotPixels(Minecraft minecraft, BufferedImage actual, RecordingScreen screen, + int[] viewport, net.minecraft.resources.Identifier texture) { + var inventory = NativeGuiEvidence.source(minecraft, SimpleMachineGUI.playerInventoryBox); + var machine = NativeGuiEvidence.source(minecraft, texture); + JsonObject proof = new JsonObject(); + proof.add("inventory_source", inventory.proof().deepCopy()); + proof.add("machine_source", machine.proof().deepCopy()); + JsonArray points = new JsonArray(), slots = new JsonArray(), slotColors = new JsonArray(), expectedPositions = new JsonArray(); + for (int[] pos : screen.expectedMachineSlots) { + JsonArray row = new JsonArray(); row.add(pos[0] - 1); row.add(pos[1] - 1); expectedPositions.add(row); + } + for (int y = 0; y < 18; y++) for (int x = 0; x < 18; x++) if (x == 0 || x == 17 || y == 0 || y == 17) { + JsonArray point = new JsonArray(); point.add(x); point.add(y); + point.add(texel(inventory.image(), 7 + x, 139 + y)); points.add(point); + } + int firstPlayer = screen.getMenu().slots.size() - 36; + int checked = 0; + for (int index = 0; index < screen.getMenu().slots.size(); index++) { + Slot slot = screen.getMenu().slots.get(index); + JsonArray position = new JsonArray(); position.add(slot.x - 1); position.add(slot.y - 1); slots.add(position); + boolean player = index >= firstPlayer; + BufferedImage source = player ? inventory.image() : machine.image(); + int u = slot.x - 1, v = player ? 132 + slot.y - 150 - 1 : slot.y - 12 - 1; + JsonArray colors = new JsonArray(); + java.util.Set contrast = new java.util.HashSet<>(); + for (int y = 0; y < 18; y++) for (int x = 0; x < 18; x++) { + if (x != 0 && x != 17 && y != 0 && y != 17) continue; + int reference = texel(source, u + x, v + y); + if (player) require(reference == texel(inventory.image(), 7 + x, 139 + y), "Player inventory border changed"); + for (int dy = 0; dy < viewport[2]; dy++) for (int dx = 0; dx < viewport[2]; dx++) { + int physicalReference = texel(source, u + x, v + y, dx, dy, viewport[2]); + colors.add(physicalReference); contrast.add(physicalReference); + int pixelX = (screen.getLeft() + slot.x - 1 + x) * viewport[2] + dx; + int pixelY = (screen.getTop() + slot.y - 1 + y) * viewport[2] + dy; + require(actual.getRGB(pixelX, pixelY) == physicalReference, + "Slot framebuffer pixels differ from independent artwork: " + index + " at " + pixelX + "," + pixelY); + checked++; + } + } + require(contrast.size() >= 2, "Slot source crop has no border contrast: " + index + "/" + texture); + slotColors.add(colors); + } + proof.add("border_points", points); proof.add("slots", slots); + proof.add("slot_border_colors", slotColors); proof.add("expected_machine_slots", expectedPositions); + proof.addProperty("logical_texture_size", 256); + proof.addProperty("source_sampling", "PHYSICAL_PIXEL_CENTERS_ON_256_LOGICAL_UV_V1"); + proof.addProperty("checked_pixels", checked); + return proof; + } + + private static int texel(BufferedImage source, int u, int v) { + return texel(source, u, v, 0, 0, 1); + } + + static int texel(BufferedImage source, int u, int v, int dx, int dy, int scale) { + require(u >= 0 && v >= 0 && u < 256 && v < 256, "Slot source UV escaped the logical GUI atlas"); + require(scale >= 1 && dx >= 0 && dx < scale && dy >= 0 && dy < scale, "Invalid physical source texel sample"); + return source.getRGB((int) ((u + (dx + 0.5F) / scale) * source.getWidth() / 256F), + (int) ((v + (dy + 0.5F) / scale) * source.getHeight() / 256F)); } private static String labelError(BufferedImage actual, BufferedImage expected, BufferedImage background, @@ -335,7 +450,7 @@ private static void require(boolean value, String message) { private static void verifyHelperLimits(Minecraft minecraft, RecordingScreen screen) throws Exception { for (String name : new String[]{"zone.moddev.mc.baseelectric.gui.GUIHelper", "zone.moddev.mc.basesteam.gui.GUIHelper"}) { - if (!net.minecraftforge.fml.ModList.get().isLoaded(name.contains("baseelectric") ? "baseelectric" : "basesteam")) continue; + if (!net.minecraftforge.fml.ModList.isLoaded(name.contains("baseelectric") ? "baseelectric" : "basesteam")) continue; String[] methods = name.contains("baseelectric") ? new String[]{"drawProgressBar"} : new String[]{"drawDownArrowProgress", "drawFlameProgress"}; for (String method : methods) { @@ -361,11 +476,13 @@ private static void verifyHelperLimits(Minecraft minecraft, RecordingScreen scre private static final class GuiWorld extends ClientLevel { private net.minecraft.world.level.block.state.BlockState state; + private net.minecraft.core.RegistryAccess registries; private GuiWorld() { super(null, null, net.minecraft.world.level.Level.OVERWORLD, null, 0, 0, null, false, 0, 0); throw new AssertionError("Allocate the read-only GUI fixture without a world constructor"); } @Override public net.minecraft.world.level.block.state.BlockState getBlockState(BlockPos pos) { return state; } + @Override public net.minecraft.core.RegistryAccess registryAccess() { return registries; } @Override public net.minecraft.world.flag.FeatureFlagSet enabledFeatures() { return net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS; } @@ -380,6 +497,7 @@ private record DrawBounds(float left, float top, float right, float bottom, int private int itemTooltipCalls; private LabelMode labels = LabelMode.PRODUCTION; private final String expectedTitle, expectedInventory; + private final List expectedMachineSlots = new ArrayList<>(); private boolean hoverTitle; private String titleTooltip; RecordingScreen(SimpleMachineGUI definition, SimpleMachineGUI.Container menu, Inventory inventory, @@ -387,9 +505,21 @@ private record DrawBounds(float left, float top, float right, float bottom, int definition.super(menu, inventory, title); this.expectedTitle = expectedTitle; this.expectedInventory = expectedInventory; + try { + Field coordinates = SimpleMachineGUI.class.getDeclaredField("inventorySlotCoordinates"); + coordinates.setAccessible(true); + for (var point : (zone.moddev.mc.basepower.math.Integer2D[]) coordinates.get(definition)) + expectedMachineSlots.add(new int[]{point.X, point.Y + 12}); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Cannot read the independent GUI slot declaration", failure); + } } - @Override protected void renderLabels(GuiGraphics graphics, int x, int y) { - if (labels == LabelMode.PRODUCTION) { super.renderLabels(graphics, x, y); return; } + private int backgroundCalls; + private boolean duplicateBackground; + private boolean garbleBackground; + + @Override protected void extractLabels(GuiGraphicsExtractor graphics, int x, int y) { + if (labels == LabelMode.PRODUCTION) { super.extractLabels(graphics, x, y); return; } if (labels == LabelMode.HIDDEN) return; if (labels != LabelMode.INVENTORY_ONLY) { String text = labels == LabelMode.WRONG_TEXT ? "Untranslated machine.name" : expectedTitle; @@ -398,20 +528,24 @@ private record DrawBounds(float left, float top, float right, float bottom, int text += "..."; } int textX = (176 - font.width(text)) / 2; - graphics.drawString(font, text, textX, labels == LabelMode.OVER_GAUGE ? 20 : 1, 0xFFFFFFFF); + graphics.text(font, text, textX, labels == LabelMode.OVER_GAUGE ? 20 : 1, 0xFFFFFFFF); } - if (labels != LabelMode.TITLE_ONLY) graphics.drawString(font, expectedInventory, 8, 140, 0xFFFFFFFF); + if (labels != LabelMode.TITLE_ONLY) graphics.text(font, expectedInventory, 8, 140, 0xFFFFFFFF); } - @Override public void render(GuiGraphics graphics, int x, int y, float ticks) { + void beginFrame() { draws.clear(); tooltipCalls = 0; itemTooltipCalls = 0; titleTooltip = null; - super.render(graphics, x, y, ticks); + backgroundCalls = 0; + } + @Override public void extractBackground(GuiGraphicsExtractor graphics, int x, int y, float ticks) { + backgroundCalls++; + super.extractBackground(graphics, x, y, ticks); } - @Override protected void renderTooltip(GuiGraphics graphics, int x, int y) { + @Override protected void extractTooltip(GuiGraphicsExtractor graphics, int x, int y) { tooltipCalls++; - super.renderTooltip(graphics, x, y); + super.extractTooltip(graphics, x, y); } - GuiGraphics recordingGraphics(Minecraft minecraft, net.minecraft.client.gui.render.state.GuiRenderState state) { - return new GuiGraphics(minecraft, state, -100, -100) { + GuiGraphicsExtractor recordingGraphics(Minecraft minecraft, net.minecraft.client.renderer.state.gui.GuiRenderState state) { + return new GuiGraphicsExtractor(minecraft, state, -100, -100) { @Override public void setTooltipForNextFrame(Font font, List lines, java.util.Optional component, net.minecraft.world.item.ItemStack item, int x, int y) { @@ -440,7 +574,7 @@ GuiGraphics recordingGraphics(Minecraft minecraft, net.minecraft.client.gui.rend bottom = Math.max(bottom, relativeY); } draws.add(new DrawBounds(left, top, right, bottom, u, v, width, height)); - super.blit(texture, x, y, u, v, width, height); + super.blit(texture, x, y, garbleBackground ? u + 1 : u, v, width, height); if (helperCheck != null) { Runnable check = helperCheck; helperCheck = null; diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/NativeExperimentalFixtureGate.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/NativeExperimentalFixtureGate.java new file mode 100644 index 00000000..09a244ad --- /dev/null +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/NativeExperimentalFixtureGate.java @@ -0,0 +1,214 @@ +package zone.moddev.mc.advantageworks.client; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.blaze3d.platform.InputConstants; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import javax.imageio.ImageIO; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.BackupConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.IntArrayTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; + +/** One approved native warning action for an authenticated, completed native broad fixture copy. */ +final class NativeExperimentalFixtureGate { + static final String PROTOCOL = "NATIVE64_SEALED_EXPERIMENTAL_FIXTURE_SKIP_V2"; + static final String SCOPE = "COMPLETED_NATIVE64_BROAD_PACKAGED_CURRENT_COPY"; + static final String TITLE = "selectWorld.backupQuestion.experimental"; + static final String SKIP = "selectWorld.backupJoinSkipButton"; + private Screen observed; + private int firstTick; + private boolean used; + private JsonObject action; + + static boolean exactTranslation(Component component, String key) { + return component.getContents() instanceof TranslatableContents contents && key.equals(contents.getKey()) + && contents.getArgs().length == 0 && contents.getFallback() == null && component.getSiblings().isEmpty(); + } + + static Button select(Class screenClass, Component title, List children) { + if (screenClass != BackupConfirmScreen.class || !exactTranslation(title, TITLE)) return null; + List