diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e311bb..a97454a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,16 @@ -name: basePower 1.21.1 CI +name: basePower 1.21.11 CI on: push: - branches: [master-1.21.1, 'feature/**'] + branches: [master-1.21.11, 'feature/**'] pull_request: - branches: [master-1.21.1] + branches: [master-1.21.11] permissions: contents: read concurrency: - group: basepower-1.21.1-${{ github.workflow }}-${{ github.ref }} + group: basepower-1.21.11-${{ 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.1 + - name: Require the Base Metals pin on upstream master-1.21.11 id: base-metals-pin env: GH_TOKEN: ${{ github.token }} @@ -34,13 +34,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/OreSpawn - ref: 62c08169c71f4d4fd8f107289e63fd2a4c7c655c + ref: ecad75b24309a344f712d898d717c6a8823fc9f2 path: .ci-dependencies/OreSpawn - name: Check out pinned Base Metals uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/BaseMetals - ref: 327c197bcdd0bb5e883d844bb6a6c8791bc580b7 + ref: 616d527e50e4bf0137eeb9c44fa586e34a0452a0 path: .ci-dependencies/BaseMetals - name: Install pinned Java 25 Mavenizer runtime uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 @@ -78,8 +78,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.121011.jar" - bmjar="$basemetals/build/libs/BaseMetals-3.1.2.121011.jar" + osjar="$orespawn/build/libs/OreSpawn-4.1.0.121111.jar" + bmjar="$basemetals/build/libs/BaseMetals-3.1.2.121111.jar" test -f "$osjar" && test -f "$bmjar" echo "orespawn=$osjar" >> "$GITHUB_OUTPUT" echo "basemetals=$bmjar" >> "$GITHUB_OUTPUT" @@ -90,25 +90,23 @@ jobs: writeReleaseChecksums verifyEclipseProductionClasspath -PbaseMetalsDeobfJar=${{ steps.dependencies.outputs.basemetals }} -PoreSpawnDeobfJar=${{ steps.dependencies.outputs.orespawn }} - -Pforge52MavenizerJar=${{ steps.dependencies.outputs.mavenizer }} + -Pforge61MavenizerJar=${{ steps.dependencies.outputs.mavenizer }} --no-daemon --stacktrace - name: Upload audited release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: basePower-1.21.1-${{ github.sha }} + name: basePower-1.21.11-${{ github.sha }} if-no-files-found: error retention-days: 30 path: | - build/libs/basePower-2.4.14.121011.jar - build/libs/basePower-2.4.14.121011-sources.jar - build/libs/basePower-2.4.14.121011-javadoc.jar + build/libs/basePower-*.jar build/release/SHA256SUMS CHANGELOG.txt - name: Upload diagnostics on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: basePower-1.21.1-diagnostics-${{ github.sha }} + name: basePower-1.21.11-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 95af822a..3d24428e 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.1, 'feature/**'] + branches: [master-1.21.11, 'feature/**'] pull_request: - branches: [master-1.21.1] + branches: [master-1.21.11] 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.1 + - name: Require the Base Metals pin on upstream master-1.21.11 id: base-metals-pin env: GH_TOKEN: ${{ github.token }} @@ -34,13 +34,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/OreSpawn - ref: 62c08169c71f4d4fd8f107289e63fd2a4c7c655c + ref: ecad75b24309a344f712d898d717c6a8823fc9f2 path: .ci-dependencies/OreSpawn - name: Check out pinned Base Metals uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/BaseMetals - ref: 327c197bcdd0bb5e883d844bb6a6c8791bc580b7 + ref: 616d527e50e4bf0137eeb9c44fa586e34a0452a0 path: .ci-dependencies/BaseMetals - name: Install pinned Java 25 Mavenizer runtime uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 @@ -79,8 +79,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.121011.jar" >> "$GITHUB_OUTPUT" - echo "basemetals=$basemetals/build/libs/BaseMetals-3.1.2.121011.jar" >> "$GITHUB_OUTPUT" + 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 "mavenizer=$mavenizer" >> "$GITHUB_OUTPUT" - name: Initialize CodeQL uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 @@ -93,6 +93,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 }} \ - -Pforge52MavenizerJar=${{ steps.dependencies.outputs.mavenizer }} + -Pforge61MavenizerJar=${{ 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 2f2c732b..b37dfc94 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.1 + - master-1.21.11 - 'feature/**' pull_request: branches: - - master-1.21.1 + - master-1.21.11 permissions: contents: read diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 7a2db808..b163f18f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,22 @@ +basePower 2.4.15.121111 (Minecraft 1.21.11) + +- Bring basePower to Forge 61 and Java 21, keeping the existing machine and world identities. +- Use native value-based saves and migration Mixins while retaining the legacy API's saved fields. +- Update recipes, item definitions, resource packs and fluid screens for the newer APIs. +- Keep old-world upgrade checks separate from the machine, restart and visual checks. +- Keep chunk-migration callbacks cooperative with Base Metals so old turbine and entity data is restored. +- Handle vanilla's chain rename and retired registry indexes when upgrading newer saved worlds. +- Keep machine and pipe item names translated with the native block-item naming rules. +- Separate block and item atlas entries so the new renderer keeps machine textures visible. +- Keep older biome registry snapshots from blocking upgrades through 1.13 saves. Minecraft still converts the saved biome names. +- Translate older Forge registry names without dropping their entries, and keep an exact recovery copy of the retired enchantment index. +- Recognise four removed vanilla sound indexes when upgrading older worlds. +- Require Base Metals in Forge's dependency checks as well as on CurseForge. +- Keep the release upload in step with the declared version. +- Run wrapper validation on the current Minecraft branch as well as the main build and CodeQL checks. +- Keep resource checks non-interactive in CI without hiding warnings or errors. +- Complete machine, upgrade, graphical and two-world endurance checks against the current jars. Manual testing is accepted; publication remains a separate step. + basePower 2.4.14.121011 (Minecraft 1.21.1) - Bring basePower to Forge 52 and Java 21, keeping existing blocks, items and machine data. diff --git a/README.md b/README.md index 02392ed9..cc85508e 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,40 @@ # basePower -**Minecraft 1.21.1 (Forge), version 2.4.14.121011.** +**Minecraft 1.21.11 (Forge), version 2.4.15.121111.** -This version keeps the existing machines and saved-world data while updating -to Forge 52 and Java 21. See the [1.21.1 test results](docs/1.21.1/TEST-RESULTS.md) -for the checked upgrade routes, machine tests and remaining modpack limits. +This version has passed automated qualification and manual testing. See the +[1.21.11 port record](docs/1.21.11/PORT-STATUS.md) for the checks and remaining +coverage limits. Release publication is handled separately; results from +earlier versions do not qualify this build. + +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. 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.1/PORT-STATUS.md) separates the automated checks from +[port record](docs/1.21.11/PORT-STATUS.md) separates the automated checks from the remaining manual gameplay and visual checks. basePower continues DrCyano's Power Advantage. It provides shared power, fluids and item transport for baseSteam and baseElectric. -## Playing On 1.21.1 +## Playing On 1.21.11 -- Install [MMD OreSpawn 4.1.0.121011](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn/files/9189594) on both client and server. -- Base Metals is optional for a new basePower world. For a world that already - uses Base Metals, keep it installed and use the matching Forge 1.21.1 build - `3.1.2.121011` or a later compatible Forge 52 release. Older Base Metals - builds do not have the shared upgrade hook. +- Install [MMD OreSpawn 4.1.0.121111](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn/files/9268464) on both client and server. +- Base Metals is required on both client and server. Use the matching Forge + 1.21.11 build `3.1.2.121111`. Older Base Metals builds do not have the shared + upgrade hook. The matching dependency must be published before the base* + releases; a local test result alone does not establish public availability. - baseSteam and baseElectric are separate mods. Use only their matching - 1.21.1 builds. Additional Loot Tables is not needed for the built-in loot. + 1.21.11 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.1 before you upgrade. +other mods in your world support 1.21.11 before you upgrade. The accepted [1.14 results](docs/1.14/BROADER-QUALIFICATION.md) are historical evidence, not qualification of this port or a whole modpack. @@ -36,22 +42,23 @@ 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.1 +## Building Minecraft 1.21.11 -The build uses Forge 52.1.0, official `1.21.1` mappings, -MCP `20240808.132146`, ForgeGradle 7.0.34 and Gradle 9.6.1. +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 automatic discovery and downloads for a reproducibility check. -Use the pinned Base Metals `3.1.2.121011` and OreSpawn `4.1.0.121011` jars. +Use the pinned Base Metals `3.1.2.121111` and OreSpawn `4.1.0.121111` jars. The source pins and checksums are in `gradle.properties`. -Forge 52 also needs the Mavenizer compatibility jar in the pinned OreSpawn -checkout's `ci-fixtures/tools/` directory. It fixes decompiled Forge sources -on a clean build and uses Java 25. It is not part of the mod or game runtime. -Set `-Pforge52MavenizerJar=` when that checkout is in another location. -If Base Metals is installed, use the pinned 3.1.2 build when testing an upgrade. It lets +Forge 61 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. +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. @@ -64,8 +71,8 @@ gradlew.bat clean check build javadoc genEclipseRuns eclipse isolateEclipseProdu The default dependency jars are: ```text -D:\BaseMetals\.verification-basemetals1211\Advantage shared hook\build\libs\BaseMetals-3.1.2.121011.jar -D:\MinecraftMineralogy\OS1211\OreSpawn\build\libs\OreSpawn-4.1.0.121011.jar +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 ``` Other layouts can pass `-PbaseMetalsDeobfJar=` and `-PoreSpawnDeobfJar=`. @@ -74,10 +81,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.1` branch; a locally tested pin +Metals pin to be on its upstream `master-1.21.11` branch; a locally tested pin does not qualify a hosted build. -Release jars are written to `build/libs`. Forge 52 uses the same mod jar for play +Release jars are written to `build/libs`. Forge 61 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: @@ -90,10 +97,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.15.121011` jars, +To prepare a combined client, build the baseSteam and baseElectric `2.2.16.121111` jars, run `gradlew.bat prepareCombinedEclipseLaunch`, refresh Eclipse, then launch `runClientAllAdvantage.launch`. Inputs must match -`test-rig/profiles/native1211/dev-core.json`; alternative layouts can pass +`test-rig/profiles/native12111/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. @@ -129,6 +136,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.1. +qualification and the checks carried into 1.21.11. 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 5fecf6ca..572a2254 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ plugins { id 'net.minecraftforge.gradle' version '7.0.34' } -apply from: 'gradle/forge52-mavenizer.gradle' +apply from: 'gradle/forge61-mavenizer.gradle' group = project.mod_group version = project.mod_version @@ -101,6 +101,7 @@ minecraft { runs { configureEach { jvmArgs '--add-opens', 'java.base/java.lang.invoke=ALL-UNNAMED' + args '--mixin', 'basepower.mixins.json' workingDir.convention layout.projectDirectory.dir(configuredRunDirectory) systemProperty 'forge.logging.markers', 'REGISTRIES' systemProperty 'forge.logging.console.level', 'info' @@ -133,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.1 launches.' + description = 'Merges classes and processed resources for Forge 1.21.11 launches.' dependsOn tasks.named('classes') from sourceSets.main.output into forgeRunModClassesDirectory @@ -169,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-basemetals1211/Advantage shared hook/build/libs/' + .getOrElse('../../../BaseMetals/.verification-basemetals12111/Advantage shared hook/build/libs/' + "BaseMetals-${project.basemetals_version}.jar")) def oreSpawnDeobfJar = file(providers.gradleProperty('oreSpawnJar') .orElse(providers.gradleProperty('oreSpawnDeobfJar')) - .getOrElse('../../../MinecraftMineralogy/OS1211/OreSpawn/build/libs/' + .getOrElse('../../../MinecraftMineralogy/OS12111/OreSpawn/build/libs/' + "OreSpawn-${project.orespawn_version}.jar")) dependencies { @@ -201,7 +202,10 @@ def unitBootstrapJar = tasks.register('unitBootstrapJar', Jar) { } tasks.withType(Test).configureEach { dependsOn unitBootstrapJar - doFirst { jvmArgs "-javaagent:${unitBootstrapJar.get().archiveFile.get().asFile}" } + doFirst { + jvmArgs "-javaagent:${unitBootstrapJar.get().archiveFile.get().asFile}" + systemProperty 'basepower.nativePackClasspath', sourceSets.test.runtimeClasspath.asPath + } } tasks.named('processResources', ProcessResources) { @@ -240,6 +244,7 @@ tasks.named('jar', Jar) { manifest { attributes([ 'Maven-Artifact' : "${project.group}:${base.archivesName.get()}:${project.version}", + 'MixinConfigs' : 'basepower.mixins.json', 'Specification-Title' : project.mod_id, 'Specification-Vendor' : project.mod_authors, 'Specification-Version' : '1', @@ -252,7 +257,7 @@ tasks.named('jar', Jar) { } } -// Forge 52 uses the official names at runtime, so the same jar serves both uses. +// Forge 61 uses the official names at runtime, so the same jar serves both uses. def releaseJar = tasks.named('jar', Jar) tasks.register('deobfJar') { @@ -302,20 +307,21 @@ def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') tasks.register('verifyReleaseConfiguration') { group = 'verification' - description = 'Validates release identity, target metadata, and optional integration policy.' + description = 'Validates release identity, target metadata, and required dependencies.' doLast { def pack = new JsonSlurper().parse(file('src/main/resources/pack.mcmeta')).pack - if (pack.pack_format != 34 || pack.supported_formats != [min_inclusive: 34, max_inclusive: 48]) { - throw new GradleException('Minecraft 1.21.1 requires resource format 34 and data format 48') + if (pack.min_format != [94, 1] || pack.max_format != [94, 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') } - if (project.mod_version != '2.4.14.121011') { + if (project.mod_version != '2.4.15.121111') { throw new GradleException("Unexpected PowerAdvantage release version: ${project.mod_version}") } - if (project.minecraft_version != '1.21.1' - || project.forge_version != '52.1.0' + if (project.minecraft_version != '1.21.11' + || project.forge_version != '61.1.0' || project.mapping_channel != 'official' - || project.mapping_version != '1.21.1' - || project.mcp_version != '20240808.132146') { + || project.mapping_version != '1.21.11' + || project.mcp_version != '20251223.124241') { throw new GradleException('Unexpected Minecraft, Forge, or mappings target') } if (project.loader_name != 'forge' || project.loader_code != '1' @@ -332,19 +338,19 @@ tasks.register('verifyReleaseConfiguration') { } if (project.cf_requirements != 'base-metals,mmd-orespawn' || project.cf_optionals != 'additional-loot-tables' - || project.cf_versions != '1.21.1') { + || project.cf_versions != '1.21.11') { throw new GradleException('Unexpected CurseForge dependency or game-version metadata') } - if (project.basemetals_version != '3.1.2.121011' - || project.orespawn_version != '4.1.0.121011' + if (project.basemetals_version != '3.1.2.121111' + || project.orespawn_version != '4.1.0.121111' || !(project.basemetals_source_commit ==~ /[a-f0-9]{40}/) - || project.orespawn_source_commit != '62c08169c71f4d4fd8f107289e63fd2a4c7c655c') { + || project.orespawn_source_commit != 'ecad75b24309a344f712d898d717c6a8823fc9f2') { 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.14.121011"') + || !mainSource.contains('VERSION = "2.4.15.121111"') || !mainSource.contains('@Mod(PowerAdvantage.MODID)')) { throw new GradleException('PowerAdvantage source identity changed') } @@ -372,13 +378,13 @@ tasks.register('verifyReleaseConfiguration') { throw new GradleException("Source mods.toml is missing ${required}") } } - if (!modsToml.contains('modId="basemetals"\nmandatory=false')) { - throw new GradleException('Base Metals must remain Forge-optional; CurseForge installs it for players') + if (!modsToml.contains('modId="basemetals"\nmandatory=true')) { + throw new GradleException('Base Metals must be required by both Forge and CurseForge') } String steelBarsTag = file( 'src/main/resources/data/forge/tags/item/bars/steel.json').getText('UTF-8') if (!steelBarsTag.contains('"required": false') || !steelBarsTag.contains('"basemetals:steel_bars"')) { - throw new GradleException('Steel bars must use Forge optional tag entries when Base Metals is absent') + throw new GradleException('Steel bars must allow individual Base Metals items to be disabled') } [ 'dusts/coal', 'dusts/lead', 'dusts/redstone', 'dusts/silver', 'dusts/tin', @@ -414,7 +420,7 @@ tasks.register('verifyReleaseConfiguration') { tasks.register('verifyReleaseDependencies') { group = 'verification' - description = 'Resolves and audits Forge plus the exact optional compile APIs.' + description = 'Resolves and audits Forge plus the exact external compile APIs.' dependsOn tasks.named('verifyReleaseConfiguration') doLast { [(baseMetalsDeobfJar): project.basemetals_deobf_sha256, @@ -542,7 +548,7 @@ tasks.register('verifyReleaseArtifacts') { new ZipFile(mainJar).withCloseable { zip -> List names = zip.entries().collect { it.name } ['META-INF/mods.toml', 'META-INF/accesstransformer.cfg', 'pack.mcmeta', - 'coremods/poweradvantage_118_migration.js', + 'basepower.mixins.json', 'zone/moddev/mc/basepower/PowerAdvantage.class', 'cyano/poweradvantage/api/PoweredEntity.class', 'cyano/poweradvantage/api/fluid/FluidNetworkApi.class', @@ -569,11 +575,11 @@ tasks.register('verifyReleaseArtifacts') { throw new GradleException('Packaged pre-flattening table access rule is incorrect') } String modsToml = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) - .getText(StandardCharsets.UTF_8.name()) + .getText(StandardCharsets.UTF_8.name()).replace('\r\n', '\n') if (!modsToml.contains('modId="basepower"') || !modsToml.contains("version=\"${project.mod_version}\"") - || !modsToml.contains('modId="basemetals"') - || !modsToml.contains('modId="orespawn"')) { + || !modsToml.contains('modId="basemetals"\nmandatory=true') + || !modsToml.contains('modId="orespawn"\nmandatory=true')) { throw new GradleException('Packaged mods.toml identity, version, or dependencies are incorrect') } def manifest = new Manifest(zip.getInputStream(zip.getEntry('META-INF/MANIFEST.MF'))).mainAttributes @@ -1245,9 +1251,9 @@ tasks.register('clientResourceProbeJar', Jar) { // The expansion jars are runtime inputs, never release contents or compile dependencies. def advantageExtensionJars = [ basesteam: file(providers.gradleProperty('steamAdvantageDeobfJar').getOrElse( - '../SteamAdvantage/SteamAdvantage/build/libs/baseSteam-2.2.15.121011.jar')), + "../SteamAdvantage/SteamAdvantage/build/libs/baseSteam-${project.basesteam_version}.jar")), baseelectric: file(providers.gradleProperty('electricAdvantageDeobfJar').getOrElse( - '../ElectricAdvantage/ElectricAdvantage/build/libs/baseElectric-2.2.15.121011.jar')) + "../ElectricAdvantage/ElectricAdvantage/build/libs/baseElectric-${project.baseelectric_version}.jar")) ] tasks.register('prepareCombinedEclipseLaunch') { @@ -1257,8 +1263,12 @@ tasks.register('prepareCombinedEclipseLaunch') { dependsOn tasks.named('deobfJar') doLast { File inputProfile = file(providers.gradleProperty('advantageDevelopmentProfile').getOrElse( - 'test-rig/profiles/native1211/dev-core.json')) + 'test-rig/profiles/native12111/dev-core.json')) def profile = new JsonSlurper().parse(inputProfile) + if (profile.mode != 'development' || profile.minecraftVersion != project.minecraft_version + || profile.forgeVersion != project.forge_version) { + throw new GradleException("Development profile targets a different Minecraft or Forge version: ${inputProfile}") + } if (profile.powerDeobfSha256) { File powerJar = tasks.named('jar', Jar).get().archiveFile.get().asFile String powerHash = MessageDigest.getInstance('SHA-256').digest(powerJar.bytes).encodeHex().toString() @@ -1268,6 +1278,9 @@ tasks.register('prepareCombinedEclipseLaunch') { } advantageExtensionJars.each { String id, File jar -> def pin = profile.mods.find { it.id == id } + if (pin == null || pin.version != project.property(id + '_version')) { + throw new GradleException("Development profile has the wrong ${id} version: ${inputProfile}") + } if (!jar.isFile()) throw new GradleException("Build ${id} first, or override its *AdvantageDeobfJar property: ${jar}") String hash = MessageDigest.getInstance('SHA-256').digest(jar.bytes).encodeHex().toString() if (pin == null || !hash.equalsIgnoreCase(pin.sha256 as String)) { diff --git a/docs/1.21.11/PORT-STATUS.md b/docs/1.21.11/PORT-STATUS.md new file mode 100644 index 00000000..393bde2f --- /dev/null +++ b/docs/1.21.11/PORT-STATUS.md @@ -0,0 +1,178 @@ +# Minecraft 1.21.11 + +This version has passed automated qualification and manual testing. Publication +is tracked separately from acceptance; see the +[current qualification record](QUALIFICATION.md). It targets Forge 61.1.0, +Java 21 and data version 4671. + +## What Changed + +The port uses native typed Forge event buses, keyed block/item properties, +value-based block-entity saves and native migration Mixins. Existing machine +names, inventories, fluid fields and the retained Cyano API remain in place. +Recipes use native string ingredients. Item definitions and the split item +atlas keep the existing artwork and model identities. + +The resource pack declares server format 94.1. Forge supplies the hidden +client aggregate with format 75 metadata. Both effective native packs are +tested through real Forge discovery, rather than treating one raw metadata +file as both pack types. Forge 61.1.0's server discovery label still compares +against the client version and says TOO_NEW; the correctly typed server pack +is compatible and accessible. This platform discrepancy is recorded, not +hidden by changing the data format. + +## Current Candidate + +The dependency-corrected Power jar has player SHA-256 +`C98D2CF163BE2480D9AFB0023BBD2D92D401AE0D50BEF73191235BF3F83774A7`. +Its full release suite passes. The qualification run passed 276 unit tests, +including fresh Eclipse compiler output. Release preparation adds a passing +277th test for the CI artifact upload path and a 278th for source-workflow branch +filters. Both pass their broken-input controls. Against the preceding registry-repair jar, only +`META-INF/mods.toml` changed; all gameplay classes and other resources are exact. +The preceding registry repair passed two additional chained 1.13 dimension +upgrades through full restart. Those earlier results and failures are retained +under their own artifact hashes, not relabelled as final combined qualification. + +The current family is **manually accepted**. Its own +machine, migration, graphical and two-world endurance reruns now pass, including +independent exact stopped-save replay. The expansions pin the same Power +source. These are new results for the current hashes, not transferred C2 +results or an expanded claim based on the dependency smoke test. + +Base Metals is required by all three mods on both client and server, matching +their CurseForge requirements. The previous Forge-optional flags were leftover +metadata and have been corrected. The no-Base-Metals robustness run below is +not a supported installation or a release qualification requirement. + +Native Forge parser tests verify the requirement in all three mods. A packaged +server starts, saves and stops with the matching Base Metals installed. Without +it, Forge reports the three missing-dependency errors before mod construction +or world creation. Minecraft handles this early failure with process exit 0, +so the negative control checks the exact dependency errors and absence of a +world rather than treating an exit code alone as proof. These are dependency +checks, not a replacement for the separate, now-completed gameplay and +endurance qualification. + +## Completed C2 Checks + +The earlier frozen C2 player jars have these SHA-256 checksums: + +| Mod | Version | SHA-256 | +| --- | --- | --- | +| basePower | 2.4.15.121111 | `134FF55595E86D450DBFB5531F389BF0BE81FE6347F1EB03765B571A1D488B59` | +| baseSteam | 2.2.16.121111 | `25EE7784EAA180C77B7735B90928801F56A7342B5733B49222E2B0E72E5577F9` | +| baseElectric | 2.2.16.121111 | `6790C193F18928F2547ECD57EC55AF6FBFC497D67FE46862146BB0C810E6B192` | + +These results refer to those bytes, not to an earlier port candidate. + +- All 257 Power unit tests and the full release and Eclipse audits pass. +- A clean build in a path containing spaces, using a new Gradle cache, + produces identical player, sources and Javadoc jars. +- Both mods' actual chunk-migration callbacks run in either order without + cancelling the companion hook. A new regression catches the early return + that previously prevented turbine restoration. +- Native data-fixer checks preserve the chain's axis and waterlogging, all + boat variants and both thrown-potion types. Only their obsolete Forge + indexes and six exact removed sound indexes are retired; unknown entries + still fail rather than being silently accepted. +- Native resource discovery reads 261 client and 160 server files, with no + warnings or errors. Opposite-format and missing-metadata controls remain. +- A disposable packaged Power world starts, saves and reopens in a new + process with the exact local Base Metals migration-hook candidate. +- The new Python adapters have positive and negative controls for command + acknowledgement, logging custody, item state and saved terrain. +- The native harness passes 379 tests and its independent Forge loader control. +- All 132 finite machine jobs pass, followed by a complete process restart + and independent inspection of both stopped saves. The tests check actual + products, consumed inputs and saved state, not just a spinning animation. +- Original 1.10 Advantage, released 1.10 and 1.12 base* machine fixtures, + and maintained 1.20.6 and 1.21.1 fixtures pass conversion, full restart + and operation without replacing the machines. +- Six-facing drill, moving drill, harvest, real chunk unload and partial-network + checks pass. Shared crushing and both oil integrations also pass after restart. +- Recipe discovery, manufacturing progression and recipe modes pass, including + the real server tag and recipe-book packets used by the graphical client. +- Advantage generation, legacy OreSpawn configuration import, Mineralogy + generation and oil transport/processing pass in disposable worlds. +- Actual graphical testing of the frozen jars passes for 94 item models, + 905 block states and all 39 machine screens. It checks translated captions, + fluid pixels and recipe-book cells, with deliberate broken controls. + This caught and led to repairs for native block-item names and the split + block/item atlas; the earlier failed evidence has been retained. + +Legal original-1.10 and maintained-1.12 player/entity fixtures pass. Nether and +End fixtures retain the Advantage data but report a separate native difference: +Minecraft changes legacy wolf health and maximum health. An independent vanilla +data-fixer control reproduces that change. It is not presented as exact +preservation of every vanilla field. Invalid historical overstacked battery +fixtures remain failures rather than accepting Minecraft's stack-size clamp. + +The Windows Forge/Netty startup probe is also run without mods. The test +launcher retains normal stack traces and all logging levels, avoiding the +extended formatter's attempt to initialize unsupported native OS classes. +Only exact, reproduced DEBUG capability probes are classified as platform +observations. ERROR events, different causes, extra exceptions and mod frames +remain failures; the original failed logs are retained. + +Both fresh and original-1.10-upgraded endurance worlds complete 30 measured +one-minute batches, a cold restart and a further minute of useful work. Each +produces the exact expected item totals and 30,000 mB of distilled output. +The source world and server seed remain unchanged. Independent stopped-save +replay confirms all 66 batches and their 1,056 station snapshots, but its exact +final comparison fails because timers and buffers kept ticking before the save. +The harness now freezes ticks across that final checkpoint/save boundary. +The separate current run in the qualification record passes the corrected +protocol with a genuine pre-run seal. No retrospective source seal is claimed +to have existed before the older C2 run. + +Both actual migration-transformer orders also pass conversion, restart and +operation. That test uses a bounded seventh test mod and audits the woven +callback order. It is separate from the normal six-jar profile, does not change +the production jars and is not a production launch mode. + +## Historical Failures And Manual Checks + +The old no-Base-Metals robustness profile failed during OreSpawn's startup tag +lookup with `IllegalStateException: Tags not bound`. Its failure is retained, +not converted to a pass. This was an unsupported dependency combination, so +repairing OreSpawn for it is not a prerequisite for the base* port. No OreSpawn +source change has been made. Current metadata must reject a missing Base Metals +dependency before constructing any of the mods. + +The two additional chained 1.13 dimension fixtures exposed a biome lookup and +four old Forge registry names. Those repairs now pass strict world tests, +including all four Steam enchantments with their levels and inventory slots. +The complete enchantment index is archived, not discarded; uppercase FML and +numeric-ID archives remain untouched. Conflict, malformed-input, idempotence +and byte-exact backup controls pass. Four precisely identified removed vanilla +sound indexes are retired; unknown names still remain unresolved. The separate +current aggregate now passes the supported automated gates; the earlier failed +receipts remain unchanged. + +Manual inspection is still needed for normal play and the in-world hydroelectric +turbine's animation and orientation. Its geometry checks do not replace seeing +the entity rendered in a real client world. Automated screens and recipe-book +checks likewise do not claim human acceptance. + +An independent source-to-test review finds no empty or skipped finite-operation +passes: the 132 jobs contain 690 successful verify-phase assertions. It also +identifies these remaining coverage limits, rather than treating a model, recipe +or station supply chest as proof of gameplay: + +- Actual rotator use and preservation of the rotated machine's contents. +- Oil immersion effects and fire callbacks. +- Timed musket loading without Rapid Reload, including cancellation. +- Turret player/team targeting and the evil turret's combat behaviour. +- Connected-player menus, shift-click, control packets and live gauge updates. +- A connected player's elevator ride and interrupted recovery aboard it. +- Fluid terminal transfer to external mods' fluid machines and faces. +- The optional BaseMinerals-present sulfur/lithium ownership tier. + +These are missing witnesses, not confirmed defects. The finite machine, +save/restart and rendering results above retain their narrower, tested scope. + +The Base Metals 3.1.2.121111 shared migration hook was local-only during +qualification. It must be published first; do not substitute the older +dependency. Always protect the original save and test a copy. Source merge, +CurseForge, Maven and GitHub publication have their own verification gates. diff --git a/docs/1.21.11/QUALIFICATION.md b/docs/1.21.11/QUALIFICATION.md new file mode 100644 index 00000000..da5a74ea --- /dev/null +++ b/docs/1.21.11/QUALIFICATION.md @@ -0,0 +1,139 @@ +# Minecraft 1.21.11 Qualification + +The supported Base Metals-installed test set passed automated qualification. +Manual testing was subsequently accepted on 2026-11-11 and publication was +authorized in dependency order. The results below remain tied to their exact +inputs; acceptance is not a claim that publication has completed. The Base +Metals migration-hook patch was local-only during qualification and must be +published before the three base* releases. + +## Tested Jars + +The tests use Forge 61.1.0 and Temurin 21.0.7+6. These are the player jars, +not loose development classes or jars from a previous candidate. + +| Mod | Version | SHA-256 | +| --- | --- | --- | +| basePower | 2.4.15.121111 | `C98D2CF163BE2480D9AFB0023BBD2D92D401AE0D50BEF73191235BF3F83774A7` | +| baseSteam | 2.2.16.121111 | `F23DDC4ABB36A1C4C7A4205151FBB6DDFD25397E86909E1C0B146C4089B747E0` | +| baseElectric | 2.2.16.121111 | `54662C9ACA83D6145D5795B1D9E9C252DB5DA085C1EC8CA1C9895B02F2BA0770` | +| Base Metals | 3.1.2.121111 | `598D3FB0FF956EBCC67FE477A01CE393C75D0013AF0CE00A79E2072B63E9F2BA` | +| OreSpawn | 4.1.0.121111 | `DE39929057288CEA110196C9E9D708E129B80BC360A2E53976337FFA6A84A67B` | + +The server harness is `1.1.5.121111`, SHA-256 +`CA244B363E4691D3A3E53032A79B09510626F6EB58C933C6602814D9BA9D118F`. +The six-jar profile SHA-256 is +`BB365614A79AF7DDA0F8C35A8B32FD962E9BA59586272A062BEFF477F2D744D6`. +Mineralogy tests add its separately verified `6.1.4.121111` jar. The +Power-only interface test deliberately omits the two expansions. Neither +variant is mistaken for a normal six-jar family run. + +## Completed Checks + +- All 132 finite machine jobs pass actual operation, a complete server + restart and independent stopped-save checks. Products and consumed inputs + are measured; an animation or a stocked supply chest is not a pass. +- The walkable 21-station works, real chunk unloading, partial networks, + bridge reconnection, extended moving drills, harvest/drop checks, fluid + interfaces and lifecycle tests pass their automated assertions. +- Five machine-upgrade routes pass conversion, full restart and operation + without rebuilding: original Advantage 1.10, released base* 1.10 and 1.12, + and maintained 1.20.6 and 1.21.1 fixtures. Each retains 14 machine witnesses. +- Three player/entity routes pass strict checks. Six Nether/End routes pass + with one separately proven vanilla difference: wolf health changes from + 8 to 16 and maximum health from 20 to 40. Other fields remain checked. +- Both actual migration-hook orders pass. That diagnostic uses a bounded + seventh test mod, verifies the woven callbacks and rejects 21 mutations; + it is not a production launch mode. +- Recipe unlocks and manufacturing progression pass in all three recipe + modes, including full restart. The graphical client uses the current + server's real recipe-book and tag packets, not older exports. +- Default generation, legacy OreSpawn import and Mineralogy generation + pass restart checks. Both crude oils pass six transport lanes and four + processing circuits, including two cold restarts and exact fluid accounting. +- Actual graphical checks pass for 94 item models, 905 block states, + 87 creative entries, 39 screens and 26 whole-gauge pixel comparisons. + The screens have 351 required-caption checks and deliberate broken controls. + Child renderers pass 25 geometry cases; live turbine animation is separate. +- Clean-path builds pass 276 Power, 250 Steam and 112 Electric unit tests, + with no failures or skips. All nine player, sources and Javadoc jars match + independent builds. Power also builds using a new Gradle cache. + Steam's fresh Eclipse compiler output also executes all 250 tests without + failures, ignored tests or assumptions. +- Four current-source Steam and four Electric native GameTests pass. Those + supplement the packaged machine tests; they do not replace their evidence. + +## Endurance And Saved Data + +A fresh world and a disposable original-1.10-upgraded world each complete +30 measured one-minute batches, a full server process restart and a further +minute of useful processing. The same frozen jars run throughout. + +Each world produces 480 conveyor items, 480 items through each of three +filter lanes, 120 products from each of five processing lanes, and 30,000 mB +of distilled output during the measured half hour. Inputs and outputs are +reconciled at every batch. The protected source and runtime seed stay unchanged. + +The candidate, Java executable and complete imported test/oracle closure +were sealed before setup. Independent replay checks all 66 checkpoints and +1,056 station snapshots. Exact stopped-save comparison passes for 263 rows +across 27 chunks in each world, including ticking buffers and timers. Ticks +are frozen across the final observation, save and shutdown; the earlier +failed timing-gap evidence remains preserved under its original jar hashes. + +The seal is `7ADC960AD1A8077ABD0E747C25C7FA5E99E370E0CD55FC133FC85443AA43B990`. +The endurance receipt is +`3EB37683A737148793119C4E185BC73AE9E2A505AB411CA565964D61C0A6ABA2`. +The independent replay receipt is +`D712C553D6498D7F0011A4FBF1D7031071AB583A963EB3D931A81EEAC88C5FEC`. + +## Repairs And Evidence Limits + +Release preparation also adds a CI upload-path regression. It first rejects +the stale 2.4.14 filenames, then passes with an upload glob following the +audited current version. A second regression catches the wrapper workflow's +old branch filter and rejects missing/wrong event targets, including misleading +comments. Hosted CI also exposed interactive terminal initialisation in the +redirected resource-proof process. The child now disables JLine explicitly; +warnings and errors still fail the proof. Three additional tests check its +launch settings, diagnostic rejection and actual native resource discovery. +The full source suite now has 281 tests; the player jar remains C98D. This +does not relabel the earlier 276-test receipt or the failed hosted runs. + +No further production change was needed during this rerun. An isolated build +caught a Steam test that ignored the configured Power jar and used a fixed +sibling path. It now receives the verified build input and rejects missing +paths or changed bytes. Its original failure and both new negative controls +are retained. The Steam player jar remains byte-identical. + +A separate oil preflight rejected a noncanonical dependency filename before +starting Minecraft. A fresh profile with the same approved bytes under the +canonical filename passed; the failed preflight was not overwritten. +Independent coverage review verifies 31 completed receipts and 37 staged +runtimes, preserving their narrower scopes and manual/skip results. +Historical whole-region hashes from before the finite-job restart cannot be +rehash-verified against the later region files; their retained raw chunk +payloads and typed state are checked separately, without claiming that missing +whole-region archives exist. The endurance replay above completes the gate +that was still pending when the coverage review was captured. None of this +converts manual checks into passes. + +## Manual Checks + +Build the Power project in Eclipse, then use `runClientAllAdvantage.launch`. +Java 21, both launch classpaths, all five production jars and 4,593 assets +have been checked. Preparing the launch does not open or modify a world. +Always use a disposable world copy. + +The remaining checks include normal play, connected-player menus and +shift-click/control packets, live gauges, actual rotator use while retaining +contents, oil immersion/fire effects, timed musket loading and cancellation, +turret player/team targeting, riding and recovering an elevator, and turbine +orientation/spin in a real world. External-mod fluid-machine faces and the +BaseMinerals-present ownership tier also lack current runtime witnesses. +The rig's `MANUAL_REQUIRED` and `SKIP` results remain explicit. This is not +qualification of every modpack, every entity or every possible layout. + +Detailed reports are retained locally under the +`native12111-qualified-metals-20261111` run prefix. Worlds, logs, dependency +jars, reports and agent notes remain outside the committed release content. diff --git a/src/main/resources/assets/basepower/textures/gui/container/machineGUI_template.png b/docs/asset-templates/machineGUI_template.png similarity index 100% rename from src/main/resources/assets/basepower/textures/gui/container/machineGUI_template.png rename to docs/asset-templates/machineGUI_template.png diff --git a/gradle.properties b/gradle.properties index 69296fad..5ba1ec41 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,43 +5,45 @@ org.gradle.caching=true org.gradle.parallel=false net.minecraftforge.gradle.merge-source-sets=false -minecraft_version=1.21.1 -minecraft_version_range=[1.21.1] -forge_version=52.1.0 -forge_installer_sha256=F1B620F2879AD6A5BBE15DABA4D8F81EAB9F5E08004967604960B98996BDEBC4 -forge_version_range=[52.1.0,53) -loader_version_range=[52,) +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,) mapping_channel=official -mapping_version=1.21.1 -mcp_version=20240808.132146 +mapping_version=1.21.11 +mcp_version=20251223.124241 loader_name=forge loader_code=1 java_version=21 java_toolchain_version=21.0.7+6 -java_setup_version=21.0.7+6 +java_setup_version=21.0.7+6.0.LTS gradle_java_version=21 curseforge_project_id=1780140 mod_id=basepower mod_name=basePower mod_license=LGPL-3.0-only -mod_version=2.4.14.121011 -release_player_sha256=F91A613EB5FAA218AAD2DC3C881DEC7B897A5EE874215282A439E34B915C1AE6 +mod_version=2.4.15.121111 +release_player_sha256=C98D2CF163BE2480D9AFB0023BBD2D92D401AE0D50BEF73191235BF3F83774A7 +basesteam_version=2.2.16.121111 +baseelectric_version=2.2.16.121111 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.1 +cf_versions=1.21.11 -basemetals_version=3.1.2.121011 -basemetals_source_commit=327c197bcdd0bb5e883d844bb6a6c8791bc580b7 +basemetals_version=3.1.2.121111 +basemetals_source_commit=616d527e50e4bf0137eeb9c44fa586e34a0452a0 basemetals_curse_project_id=240967 -basemetals_deobf_sha256=7C0B961A8D4991C9B69379518AAD700A71C1E4B2076CF5DEF292C58029408A07 +basemetals_deobf_sha256=598D3FB0FF956EBCC67FE477A01CE393C75D0013AF0CE00A79E2072B63E9F2BA -orespawn_version=4.1.0.121011 -orespawn_source_commit=62c08169c71f4d4fd8f107289e63fd2a4c7c655c +orespawn_version=4.1.0.121111 +orespawn_source_commit=ecad75b24309a344f712d898d717c6a8823fc9f2 orespawn_curse_project_id=245586 -orespawn_deobf_sha256=EB369B7D7CE6D6C453658347A420C2F0783B7F4D1AC7383C87176AC05192B5A0 +orespawn_deobf_sha256=DE39929057288CEA110196C9E9D708E129B80BC360A2E53976337FFA6A84A67B diff --git a/gradle/forge52-mavenizer.gradle b/gradle/forge52-mavenizer.gradle deleted file mode 100644 index 46987e3c..00000000 --- a/gradle/forge52-mavenizer.gradle +++ /dev/null @@ -1,23 +0,0 @@ -import java.security.MessageDigest -import org.gradle.jvm.toolchain.JvmVendorSpec - -// Use the same source preparation tool as OreSpawn on Forge 52. -def tool = 'ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar' -def defaultJar = project.extensions.extraProperties.has('forge52MavenizerDefault') - ? project.forge52MavenizerDefault - : "../../../MinecraftMineralogy/OS1211/OreSpawn/${tool}" -def fixture = file(providers.gradleProperty('forge52MavenizerJar').getOrElse(defaultJar)) -def expected = '019032A4A26ECB473D403EF017054D5892CE59A9904E8831B452287CD756397E' -if (!fixture.isFile() || MessageDigest.getInstance('SHA-256').digest(fixture.bytes) - .encodeHex().toString().toUpperCase(Locale.ROOT) != expected) { - throw new GradleException("Missing or altered Forge 52 Mavenizer fixture: ${fixture}") -} - -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/forge61-mavenizer.gradle b/gradle/forge61-mavenizer.gradle new file mode 100644 index 00000000..072e418f --- /dev/null +++ b/gradle/forge61-mavenizer.gradle @@ -0,0 +1,29 @@ +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/src/main/java/cyano/poweradvantage/api/ConduitBlock.java b/src/main/java/cyano/poweradvantage/api/ConduitBlock.java index 49814def..cb7242a2 100644 --- a/src/main/java/cyano/poweradvantage/api/ConduitBlock.java +++ b/src/main/java/cyano/poweradvantage/api/ConduitBlock.java @@ -39,21 +39,30 @@ public void onPlace(BlockState state, Level w, BlockPos coord, BlockState oldSta /** * This method is called when the block is removed from the world by an entity. */ - @Override public void onRemove(BlockState state, Level w, BlockPos coord, BlockState newState, boolean isMoving) { if (state.getBlock() != newState.getBlock()) { ConduitRegistry.getInstance().conduitBlockRemovedEvent(w, coord, getTypes()); } - super.onRemove(state, w, coord, newState, isMoving); } + @Override + protected void affectNeighborsAfterRemoval(BlockState state, net.minecraft.server.level.ServerLevel world, + BlockPos pos, boolean moving) { + onRemove(state, world, pos, world.getBlockState(pos), moving); + super.affectNeighborsAfterRemoval(state, world, pos, moving); + } + /** * This method is called when the block is destroyed by an explosion. */ - @Override public void wasExploded(Level w, BlockPos coord, Explosion boom) { - super.wasExploded(w, coord, boom); ConduitRegistry.getInstance().conduitBlockRemovedEvent(w, coord, getTypes()); } + @Override + public void wasExploded(net.minecraft.server.level.ServerLevel world, BlockPos pos, Explosion explosion) { + super.wasExploded(world, pos, explosion); + wasExploded((Level) world, pos, explosion); + } + } diff --git a/src/main/java/cyano/poweradvantage/api/FluidCategoryRegistry.java b/src/main/java/cyano/poweradvantage/api/FluidCategoryRegistry.java index 333fb963..69eacf47 100644 --- a/src/main/java/cyano/poweradvantage/api/FluidCategoryRegistry.java +++ b/src/main/java/cyano/poweradvantage/api/FluidCategoryRegistry.java @@ -8,7 +8,7 @@ import java.util.Set; import net.minecraft.world.level.material.Fluid; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; /** @@ -46,7 +46,7 @@ public static synchronized boolean registerAlias(String category, String fluidNa /** Returns true when the fluid's registry name belongs to the category. */ public static synchronized boolean matches(String category, Fluid fluid) { if (fluid == null) return false; - ResourceLocation id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); + Identifier id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); return id != null && matches(category, id.toString()); } diff --git a/src/main/java/cyano/poweradvantage/api/GUIBlock.java b/src/main/java/cyano/poweradvantage/api/GUIBlock.java index 31983431..c08e7a39 100644 --- a/src/main/java/cyano/poweradvantage/api/GUIBlock.java +++ b/src/main/java/cyano/poweradvantage/api/GUIBlock.java @@ -17,7 +17,7 @@ import net.minecraft.core.Direction; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; -import net.minecraft.world.ItemInteractionResult; +import net.minecraft.world.InteractionResult; import net.minecraft.world.MenuProvider; import net.minecraft.world.item.ItemStack; import net.minecraft.world.phys.BlockHitResult; @@ -53,6 +53,13 @@ protected GUIBlock(Block.Properties properties) { super(properties); } + public int getAnalogOutputSignal(BlockState state, Level world, BlockPos pos) { return 0; } + + @Override + protected int getAnalogOutputSignal(BlockState state, Level world, BlockPos pos, Direction side) { + return getAnalogOutputSignal(state, world, pos); + } + @Override protected boolean triggerEvent(BlockState state, Level level, BlockPos pos, int id, int data) { super.triggerEvent(state, level, pos, id, data); @@ -156,7 +163,7 @@ public RenderShape getRenderShape(BlockState bs) { public InteractionResult use(BlockState bs, Level w, BlockPos coord, Player player, InteractionHand hand, net.minecraft.world.phys.BlockHitResult hit) { Direction facing = hit.getDirection(); - if (w.isClientSide) { + if (w.isClientSide()) { return InteractionResult.SUCCESS; } final BlockEntity tileEntity = w.getBlockEntity(coord); @@ -181,31 +188,35 @@ public InteractionResult use(BlockState bs, Level w, BlockPos coord, } @Override - protected ItemInteractionResult useItemOn(ItemStack item, BlockState state, Level level, + protected InteractionResult useItemOn(ItemStack item, BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult hit) { - return switch (use(state, level, pos, player, hand, hit)) { - case SUCCESS, SUCCESS_NO_ITEM_USED -> ItemInteractionResult.SUCCESS; - case CONSUME -> ItemInteractionResult.CONSUME; - case CONSUME_PARTIAL -> ItemInteractionResult.CONSUME_PARTIAL; - case FAIL -> ItemInteractionResult.FAIL; - case PASS -> ItemInteractionResult.SKIP_DEFAULT_BLOCK_INTERACTION; - }; + return use(state, level, pos, player, hand, hit); } - @Override public void onRemove(BlockState state, Level world, BlockPos pos, BlockState newState, boolean isMoving) { if (state.getBlock() != newState.getBlock()) { - BlockEntity te = world.getBlockEntity(pos); - if (te instanceof Container) { - Containers.dropContents(world, pos, (Container) te); - ((Container) te).clearContent(); - world .updateNeighbourForOutputSignal(pos, this); - } - super.onRemove(state, world, pos, newState, isMoving); + world.updateNeighbourForOutputSignal(pos, this); } } + @Override + protected void affectNeighborsAfterRemoval(BlockState state, net.minecraft.server.level.ServerLevel world, + BlockPos pos, boolean moving) { + // Native BlockEntity.preRemoveSideEffects drops inventories before this callback. + onRemove(state, world, pos, world.getBlockState(pos), moving); + super.affectNeighborsAfterRemoval(state, world, pos, moving); + } + + public void wasExploded(Level world, BlockPos pos, net.minecraft.world.level.Explosion explosion) {} + + @Override + public void wasExploded(net.minecraft.server.level.ServerLevel world, BlockPos pos, + net.minecraft.world.level.Explosion explosion) { + super.wasExploded(world, pos, explosion); + wasExploded((Level) world, pos, explosion); + } + } diff --git a/src/main/java/cyano/poweradvantage/api/PoweredEntity.java b/src/main/java/cyano/poweradvantage/api/PoweredEntity.java index fd520078..fc362401 100644 --- a/src/main/java/cyano/poweradvantage/api/PoweredEntity.java +++ b/src/main/java/cyano/poweradvantage/api/PoweredEntity.java @@ -142,7 +142,7 @@ public float subtractEnergy(float energy, ConduitType type) { * Tells Minecraft to synchronize this tile entity */ protected final void sync() { - if (this.level == null || this.level.isClientSide) return; + if (this.level == null || this.level.isClientSide()) return; this.setChanged(); BlockState state = this.level.getBlockState(getBlockPos()); this.level.sendBlockUpdated(getBlockPos(), state, state, 2); @@ -173,7 +173,7 @@ protected final BlockPos powerAdvantagePos() { public void onLoad() { super.onLoad(); this.conduitRefreshDelay = 2; - if (this.level != null && !this.level.isClientSide) { + if (this.level != null && !this.level.isClientSide()) { ConduitRegistry.getInstance().conduitBlockLoadedEvent(this.level, this.getTypes()); } } @@ -216,7 +216,7 @@ private void markMachineStateUnsaved() { .getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4); // Timers and externally supplied energy can change without a GUI sync. // Mark the loaded chunk only; do not load neighbours or notify comparators. - if (chunk != null) chunk.setUnsaved(true); + if (chunk != null) chunk.markUnsaved(); } private void refreshConduitNetworkAfterLoad() { @@ -247,7 +247,7 @@ private void refreshConduitNetworkAfterLoad() { * @return true if on server world, false otherwise */ public boolean isServer() { - return !this.getLevel().isClientSide; + return !this.getLevel().isClientSide(); } /** @@ -257,7 +257,7 @@ public boolean isServer() { * @return false if on server world, true if on client */ public boolean isClient() { - return this.getLevel().isClientSide; + return this.getLevel().isClientSide(); } /** @@ -280,11 +280,16 @@ private final int getTickOffset() { * packet. */ @Override + protected void loadAdditional(net.minecraft.world.level.storage.ValueInput input) { + super.loadAdditional(input); + loadAdditional(zone.moddev.mc.basepower.util.LegacyNbtFields.read(input), input.lookup()); + } + + /** Compatibility hook for add-ons which persist the established flat NBT schema. */ protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider registries) { - super.loadAdditional(tagRoot, registries); this.conduitRefreshDelay = 2; - if (tagRoot.contains("Energy", 11)) { - int[] data = tagRoot.getIntArray("Energy"); + if (tagRoot.get("Energy") instanceof net.minecraft.nbt.IntArrayTag energy) { + int[] data = energy.getAsIntArray(); for (int i = 0; i < data.length; i++) { if (i >= this.getTypes().length) break; this.setEnergy(Float.intBitsToFloat(data[i]), this.getTypes()[i]); @@ -297,8 +302,15 @@ protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider r * the network. */ @Override + protected void saveAdditional(net.minecraft.world.level.storage.ValueOutput output) { + super.saveAdditional(output); + CompoundTag fields = new CompoundTag(); + saveAdditional(fields, zone.moddev.mc.basepower.util.LegacyNbtFields.registries(output, serializationRegistries())); + zone.moddev.mc.basepower.util.LegacyNbtFields.write(output, fields); + } + + /** Compatibility hook; the native writer retains these fields at the root. */ protected void saveAdditional(final CompoundTag tagRoot, HolderLookup.Provider registries) { - super.saveAdditional(tagRoot, registries); int[] data = new int[this.getTypes().length]; for (int i = 0; i < data.length; i++) { data[i] = Float.floatToIntBits(this.getEnergy(this.getTypes()[i])); @@ -312,6 +324,18 @@ private HolderLookup.Provider serializationRegistries() { : getLevel().registryAccess(); } + /** Reads an existing compound through Minecraft's registry-aware native reader. */ + public final void loadWithComponents(CompoundTag tag, HolderLookup.Provider registries) { + loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create( + net.minecraft.util.ProblemReporter.DISCARDING, registries, tag)); + } + + /** Reads only custom fields without changing the established add-on entry point. */ + public final void loadCustomOnly(CompoundTag tag, HolderLookup.Provider registries) { + loadCustomOnly(net.minecraft.world.level.storage.TagValueInput.create( + net.minecraft.util.ProblemReporter.DISCARDING, registries, tag)); + } + /** @deprecated Prefer the registry-aware Minecraft save methods. */ @Deprecated public CompoundTag save(CompoundTag tag) { return tag.merge(saveWithFullMetadata(serializationRegistries())); diff --git a/src/main/java/cyano/poweradvantage/api/fluid/ColoredFluid.java b/src/main/java/cyano/poweradvantage/api/fluid/ColoredFluid.java index 70f16cf7..e0771fb4 100644 --- a/src/main/java/cyano/poweradvantage/api/fluid/ColoredFluid.java +++ b/src/main/java/cyano/poweradvantage/api/fluid/ColoredFluid.java @@ -1,6 +1,6 @@ package cyano.poweradvantage.api.fluid; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; /** * Legacy description of a colored fluid. @@ -14,11 +14,11 @@ public final class ColoredFluid { private final String fluidName; - private final ResourceLocation still; - private final ResourceLocation flowing; + private final Identifier still; + private final Identifier flowing; private final int color; - public ColoredFluid(String fluidName, ResourceLocation still, ResourceLocation flowing, int tintARGB) { + public ColoredFluid(String fluidName, Identifier still, Identifier flowing, int tintARGB) { this.fluidName = fluidName; this.still = still; this.flowing = flowing; @@ -29,11 +29,11 @@ public String getName() { return fluidName; } - public ResourceLocation getStill() { + public Identifier getStill() { return still; } - public ResourceLocation getFlowing() { + public Identifier getFlowing() { return flowing; } diff --git a/src/main/java/cyano/poweradvantage/api/fluid/FluidNetworkApi.java b/src/main/java/cyano/poweradvantage/api/fluid/FluidNetworkApi.java index 14eb54bd..cb0786f5 100644 --- a/src/main/java/cyano/poweradvantage/api/fluid/FluidNetworkApi.java +++ b/src/main/java/cyano/poweradvantage/api/fluid/FluidNetworkApi.java @@ -41,7 +41,7 @@ public static int offerFluid(Level world, BlockPos conduitPosition, FluidStack o */ public static int offerFluid(Level world, BlockPos conduitPosition, FluidStack offered, int maxTransfer, byte minimumPriority, @Nullable Object source) { - if (world == null || world.isClientSide || conduitPosition == null || offered == null + if (world == null || world.isClientSide() || conduitPosition == null || offered == null || offered.getFluid() == null || offered.amount <= 0 || maxTransfer <= 0 || !world.hasChunkAt(conduitPosition)) { return 0; diff --git a/src/main/java/cyano/poweradvantage/api/fluid/FluidPoweredEntity.java b/src/main/java/cyano/poweradvantage/api/fluid/FluidPoweredEntity.java index 6e56b617..60d9afde 100644 --- a/src/main/java/cyano/poweradvantage/api/fluid/FluidPoweredEntity.java +++ b/src/main/java/cyano/poweradvantage/api/fluid/FluidPoweredEntity.java @@ -97,10 +97,10 @@ protected void saveAdditional(final CompoundTag tagRoot, HolderLookup.Provider r @Override protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider registries) { super.loadAdditional(tagRoot, registries); - if (tagRoot.contains("Tank", 10)) { - CompoundTag tankTag = tagRoot .getCompound("Tank"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tagRoot, "Tank", 10)) { + CompoundTag tankTag = tagRoot .getCompoundOrEmpty("Tank"); getTank().readFromNBT(tankTag); - if (tankTag.contains("Empty", 1)) { + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tankTag, "Empty", 1)) { // empty the tank if NBT says its empty (not default behavior of Tank.readFromNBT(...) ) getTank().setFluid(null); } diff --git a/src/main/java/cyano/poweradvantage/api/fluid/FluidStack.java b/src/main/java/cyano/poweradvantage/api/fluid/FluidStack.java index f24dc411..bf57bd00 100644 --- a/src/main/java/cyano/poweradvantage/api/fluid/FluidStack.java +++ b/src/main/java/cyano/poweradvantage/api/fluid/FluidStack.java @@ -4,7 +4,7 @@ import net.minecraft.world.level.material.Fluid; import net.minecraft.nbt.CompoundTag; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; import zone.moddev.mc.basepower.init.Fluids; @@ -37,7 +37,7 @@ public boolean isFluidEqual(@Nullable FluidStack other) { } public CompoundTag writeToNBT(CompoundTag nbt) { - ResourceLocation id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); + Identifier id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); if (id != null) nbt.putString("FluidName", id.toString()); nbt.putInt("Amount", amount); if (tag != null) nbt.put("Tag", tag.copy()); @@ -46,11 +46,11 @@ public CompoundTag writeToNBT(CompoundTag nbt) { @Nullable public static FluidStack loadFluidStackFromNBT(CompoundTag nbt) { - if (nbt == null || !nbt.contains("FluidName", 8)) return null; - Fluid fluid = Fluids.byName(nbt.getString("FluidName")); + if (nbt == null || !zone.moddev.mc.basepower.util.NbtCompat.containsTag(nbt, "FluidName", 8)) return null; + Fluid fluid = Fluids.byName(nbt.getStringOr("FluidName", "")); if (fluid == null) return null; - FluidStack stack = new FluidStack(fluid, nbt.getInt("Amount")); - if (nbt.contains("Tag", 10)) stack.tag = nbt.getCompound("Tag").copy(); + FluidStack stack = new FluidStack(fluid, nbt.getIntOr("Amount", 0)); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(nbt, "Tag", 10)) stack.tag = nbt.getCompoundOrEmpty("Tag").copy(); return stack; } } diff --git a/src/main/java/cyano/poweradvantage/api/fluid/FluidTank.java b/src/main/java/cyano/poweradvantage/api/fluid/FluidTank.java index 69d45666..ae107be1 100644 --- a/src/main/java/cyano/poweradvantage/api/fluid/FluidTank.java +++ b/src/main/java/cyano/poweradvantage/api/fluid/FluidTank.java @@ -78,7 +78,7 @@ public CompoundTag writeToNBT(CompoundTag nbt) { } public FluidTank readFromNBT(CompoundTag nbt) { - setFluid(nbt.contains("Empty", 1) ? null : FluidStack.loadFluidStackFromNBT(nbt)); + setFluid(zone.moddev.mc.basepower.util.NbtCompat.containsTag(nbt, "Empty", 1) ? null : FluidStack.loadFluidStackFromNBT(nbt)); return this; } } diff --git a/src/main/java/cyano/poweradvantage/api/fluid/InteractiveFluidBlock.java b/src/main/java/cyano/poweradvantage/api/fluid/InteractiveFluidBlock.java index 10225245..f33739dc 100644 --- a/src/main/java/cyano/poweradvantage/api/fluid/InteractiveFluidBlock.java +++ b/src/main/java/cyano/poweradvantage/api/fluid/InteractiveFluidBlock.java @@ -32,7 +32,8 @@ public InteractiveFluidBlock(Supplier fluid, Block.Prope } @Override - public void entityInside(BlockState state, Level world, BlockPos pos, Entity entity) { + public void entityInside(BlockState state, Level world, BlockPos pos, Entity entity, + net.minecraft.world.entity.InsideBlockEffectApplier effects, boolean moving) { if (immersionEffect != null && entity instanceof LivingEntity) { immersionEffect.accept(world, (LivingEntity) entity); } diff --git a/src/main/java/cyano/poweradvantage/api/migration/LegacyMigrationApi.java b/src/main/java/cyano/poweradvantage/api/migration/LegacyMigrationApi.java index e103ac4c..12a520eb 100644 --- a/src/main/java/cyano/poweradvantage/api/migration/LegacyMigrationApi.java +++ b/src/main/java/cyano/poweradvantage/api/migration/LegacyMigrationApi.java @@ -10,7 +10,7 @@ import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.nbt.CompoundTag; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; /** * Registration surface for Advantage add-ons that need pre-flattening world @@ -30,14 +30,14 @@ public interface NbtMigration { private static final Set NAMESPACES = new LinkedHashSet<>(); private static final Map NAMESPACE_ALIASES = new LinkedHashMap<>(); - private static final Map BLOCK_ALIASES = new LinkedHashMap<>(); - private static final Map ITEM_ALIASES = new LinkedHashMap<>(); - private static final Map BLOCK_CODECS = new LinkedHashMap<>(); + private static final Map BLOCK_ALIASES = new LinkedHashMap<>(); + private static final Map ITEM_ALIASES = new LinkedHashMap<>(); + private static final Map BLOCK_CODECS = new LinkedHashMap<>(); private static final Map TILE_ALIASES = new LinkedHashMap<>(); private static final Map ENTITY_ALIASES = new LinkedHashMap<>(); private static final Map FLUID_ALIASES = new LinkedHashMap<>(); - private static final Map FLUID_BUCKETS = new LinkedHashMap<>(); - private static final Set DURABLE_ITEMS = new LinkedHashSet<>(); + private static final Map FLUID_BUCKETS = new LinkedHashMap<>(); + private static final Set DURABLE_ITEMS = new LinkedHashSet<>(); private static final Map NBT_MIGRATIONS = new LinkedHashMap<>(); private LegacyMigrationApi() {} @@ -55,22 +55,22 @@ public static synchronized void registerNamespaceAlias(String legacyNamespace, S NAMESPACE_ALIASES.put(legacy, current); } - public static synchronized ResourceLocation registryTarget(ResourceLocation id) { + public static synchronized Identifier registryTarget(Identifier id) { String current = NAMESPACE_ALIASES.get(id.getNamespace()); - return current == null ? id : ResourceLocation.fromNamespaceAndPath(current, id.getPath()); + return current == null ? id : Identifier.fromNamespaceAndPath(current, id.getPath()); } - public static synchronized void registerBlockAlias(ResourceLocation legacyId, ResourceLocation currentId) { + public static synchronized void registerBlockAlias(Identifier legacyId, Identifier currentId) { BLOCK_ALIASES.put(legacyId, currentId); registerNamespace(legacyId.getNamespace()); } - public static synchronized void registerItemAlias(ResourceLocation legacyId, ResourceLocation currentId) { + public static synchronized void registerItemAlias(Identifier legacyId, Identifier currentId) { ITEM_ALIASES.put(legacyId, currentId); registerNamespace(legacyId.getNamespace()); } - public static synchronized void registerBlockStateCodec(ResourceLocation legacyId, BlockStateCodec codec) { + public static synchronized void registerBlockStateCodec(Identifier legacyId, BlockStateCodec codec) { if (codec == null) throw new NullPointerException("codec"); BLOCK_CODECS.put(legacyId, codec); registerNamespace(legacyId.getNamespace()); @@ -80,7 +80,7 @@ public static synchronized void registerTileEntityAlias(String legacyId, String TILE_ALIASES.put(normalizeIdentity(legacyId), normalizeIdentity(currentId)); } - public static synchronized void registerTileEntityAliases(ResourceLocation current) { + public static synchronized void registerTileEntityAliases(Identifier current) { String path = current.getPath(); registerTileVariants(current.getNamespace(), path, current.toString()); for (Map.Entry alias : NAMESPACE_ALIASES.entrySet()) { @@ -104,11 +104,11 @@ public static synchronized void registerFluidAlias(String legacyName, String cur FLUID_ALIASES.put(normalizeIdentity(legacyName), normalizeIdentity(currentName)); } - public static synchronized void registerLegacyFluidBucket(String legacyFluidName, ResourceLocation bucketId) { + public static synchronized void registerLegacyFluidBucket(String legacyFluidName, Identifier bucketId) { FLUID_BUCKETS.put(normalizeIdentity(legacyFluidName), bucketId); } - public static synchronized void registerDurableItem(ResourceLocation itemId) { + public static synchronized void registerDurableItem(Identifier itemId) { DURABLE_ITEMS.add(itemId); } @@ -121,17 +121,17 @@ public static synchronized boolean handlesNamespace(String namespace) { return NAMESPACES.contains(normalize(namespace)); } - public static synchronized ResourceLocation blockTarget(ResourceLocation legacyId) { - ResourceLocation target = BLOCK_ALIASES.get(legacyId); + public static synchronized Identifier blockTarget(Identifier legacyId) { + Identifier target = BLOCK_ALIASES.get(legacyId); return target == null ? registryTarget(legacyId) : target; } - public static synchronized ResourceLocation itemTarget(ResourceLocation legacyId) { - ResourceLocation target = ITEM_ALIASES.get(legacyId); + public static synchronized Identifier itemTarget(Identifier legacyId) { + Identifier target = ITEM_ALIASES.get(legacyId); return target == null ? registryTarget(legacyId) : target; } - public static synchronized BlockState decodeBlockState(ResourceLocation legacyId, Block block, int metadata) { + public static synchronized BlockState decodeBlockState(Identifier legacyId, Block block, int metadata) { BlockStateCodec codec = BLOCK_CODECS.get(legacyId); if (codec == null) codec = BLOCK_CODECS.get(blockTarget(legacyId)); return codec == null ? block.defaultBlockState() : codec.decode(block, metadata & 15); @@ -149,15 +149,15 @@ public static synchronized String fluidTarget(String legacyName) { return alias(FLUID_ALIASES, legacyName); } - public static synchronized ResourceLocation bucketTarget(String legacyFluidName) { + public static synchronized Identifier bucketTarget(String legacyFluidName) { String normalized = normalizeIdentity(legacyFluidName); - ResourceLocation direct = FLUID_BUCKETS.get(normalized); + Identifier direct = FLUID_BUCKETS.get(normalized); if (direct != null) return direct; String fluidTarget = alias(FLUID_ALIASES, normalized); return FLUID_BUCKETS.get(fluidTarget); } - public static synchronized boolean isDurableItem(ResourceLocation id) { + public static synchronized boolean isDurableItem(Identifier id) { return DURABLE_ITEMS.contains(id) || DURABLE_ITEMS.contains(itemTarget(id)); } @@ -171,7 +171,7 @@ public static synchronized Set namespaces() { return Collections.unmodifiableSet(new LinkedHashSet<>(NAMESPACES)); } - public static synchronized Map blockStateCodecs() { + public static synchronized Map blockStateCodecs() { return Collections.unmodifiableMap(new LinkedHashMap<>(BLOCK_CODECS)); } diff --git a/src/main/java/cyano/poweradvantage/api/modsupport/TileEntityConverter.java b/src/main/java/cyano/poweradvantage/api/modsupport/TileEntityConverter.java index 5c5b752d..2eb87970 100644 --- a/src/main/java/cyano/poweradvantage/api/modsupport/TileEntityConverter.java +++ b/src/main/java/cyano/poweradvantage/api/modsupport/TileEntityConverter.java @@ -177,8 +177,8 @@ public byte getMinimumSinkPriority() { @Override protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider registries) { super.loadAdditional(tagRoot, registries); - if (tagRoot.contains("Other", 99)) { - this.setOtherEnergy(tagRoot.getDouble("Other")); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tagRoot, "Other", 99)) { + this.setOtherEnergy(tagRoot.getDoubleOr("Other", 0D)); } } diff --git a/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFElectricityConverter.java b/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFElectricityConverter.java index d23807fd..3f572b6e 100644 --- a/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFElectricityConverter.java +++ b/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFElectricityConverter.java @@ -1,12 +1,12 @@ package cyano.poweradvantage.api.modsupport.rf; import cyano.poweradvantage.api.ConduitType; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityRFElectricityConverter extends TileEntityRFConverter { public TileEntityRFElectricityConverter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.RF_ELECTRICITY_CONVERTER, pos, state, new ConduitType("electricity"), 8000, 1000, + super(zone.moddev.mc.basepower.init.Entities.RF_ELECTRICITY_CONVERTER, pos, state, new ConduitType("electricity"), 8000, 1000, "tile.basepower.converter_rf_electricity.name"); } diff --git a/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFQuantumConverter.java b/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFQuantumConverter.java index ad3292fb..453eefed 100644 --- a/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFQuantumConverter.java +++ b/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFQuantumConverter.java @@ -1,12 +1,12 @@ package cyano.poweradvantage.api.modsupport.rf; import cyano.poweradvantage.api.ConduitType; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityRFQuantumConverter extends TileEntityRFConverter { public TileEntityRFQuantumConverter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.RF_QUANTUM_CONVERTER, pos, state, new ConduitType("quantum"), 1000, 1000, + super(zone.moddev.mc.basepower.init.Entities.RF_QUANTUM_CONVERTER, pos, state, new ConduitType("quantum"), 1000, 1000, "tile.basepower.converter_rf_quantum.name"); } diff --git a/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFSteamConverter.java b/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFSteamConverter.java index ecdd0e8d..313ac065 100644 --- a/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFSteamConverter.java +++ b/src/main/java/cyano/poweradvantage/api/modsupport/rf/TileEntityRFSteamConverter.java @@ -1,12 +1,12 @@ package cyano.poweradvantage.api.modsupport.rf; import cyano.poweradvantage.api.ConduitType; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityRFSteamConverter extends TileEntityRFConverter { public TileEntityRFSteamConverter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.RF_STEAM_CONVERTER, pos, state, new ConduitType("steam"), 1000, 1000, + super(zone.moddev.mc.basepower.init.Entities.RF_STEAM_CONVERTER, pos, state, new ConduitType("steam"), 1000, 1000, "tile.basepower.converter_rf_steam.name"); } diff --git a/src/main/java/cyano/poweradvantage/api/modsupport/techreborn/TileEntityTRElectricityConverter.java b/src/main/java/cyano/poweradvantage/api/modsupport/techreborn/TileEntityTRElectricityConverter.java index a6c869ac..bc52efee 100644 --- a/src/main/java/cyano/poweradvantage/api/modsupport/techreborn/TileEntityTRElectricityConverter.java +++ b/src/main/java/cyano/poweradvantage/api/modsupport/techreborn/TileEntityTRElectricityConverter.java @@ -2,14 +2,14 @@ import cyano.poweradvantage.api.ConduitType; import net.minecraft.world.entity.player.Player; -import zone.moddev.mc.basepower.init.Entities; + /** * Created by Chris on 4/10/2016. */ public class TileEntityTRElectricityConverter extends TileEntityTRConverter { public TileEntityTRElectricityConverter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.TR_ELECTRICITY_CONVERTER, pos, state, new ConduitType("electricity"), + super(zone.moddev.mc.basepower.init.Entities.TR_ELECTRICITY_CONVERTER, pos, state, new ConduitType("electricity"), "tile.basepower.converter_tr_electricity.name"); } diff --git a/src/main/java/cyano/poweradvantage/api/simple/BlockSimpleFluidConduit.java b/src/main/java/cyano/poweradvantage/api/simple/BlockSimpleFluidConduit.java index c6ed71d9..d6acef46 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/BlockSimpleFluidConduit.java +++ b/src/main/java/cyano/poweradvantage/api/simple/BlockSimpleFluidConduit.java @@ -52,8 +52,9 @@ public BlockState getStateForPlacement(BlockPlaceContext context) { } @Override - public BlockState updateShape(BlockState state, Direction facing, BlockState facingState, - LevelAccessor world, BlockPos currentPos, BlockPos facingPos) { + public BlockState updateShape(BlockState state, net.minecraft.world.level.LevelReader world, + net.minecraft.world.level.ScheduledTickAccess ticks, BlockPos currentPos, Direction facing, + BlockPos facingPos, BlockState facingState, net.minecraft.util.RandomSource random) { return state.setValue(property(facing), canConnectTo(world, currentPos, state, facing)); } diff --git a/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerConduit.java b/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerConduit.java index a9b14d97..7a348043 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerConduit.java +++ b/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerConduit.java @@ -62,8 +62,9 @@ public BlockState getStateForPlacement(BlockPlaceContext context) { } @Override - public BlockState updateShape(BlockState state, Direction facing, BlockState facingState, - LevelAccessor world, BlockPos currentPos, BlockPos facingPos) { + public BlockState updateShape(BlockState state, net.minecraft.world.level.LevelReader world, + net.minecraft.world.level.ScheduledTickAccess ticks, BlockPos currentPos, Direction facing, + BlockPos facingPos, BlockState facingState, net.minecraft.util.RandomSource random) { return state.setValue(property(facing), canConnectTo(world, currentPos, state, facing, facingPos)); } diff --git a/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerMachine.java b/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerMachine.java index 73491499..565ef82f 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerMachine.java +++ b/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerMachine.java @@ -4,7 +4,7 @@ import zone.moddev.mc.basepower.conduitnetwork.ConduitRegistry; import net.minecraft.world.level.block.HorizontalDirectionalBlock; import net.minecraft.world.level.block.state.properties.Property; -import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.level.block.state.properties.EnumProperty; import net.minecraft.world.level.block.state.StateDefinition; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.entity.LivingEntity; @@ -40,7 +40,7 @@ * */ public abstract class BlockSimplePowerMachine extends GUIBlock implements ITypedConduit { - public static final DirectionProperty FACING = HorizontalDirectionalBlock.FACING; + public static final EnumProperty FACING = HorizontalDirectionalBlock.FACING; private final ConduitType[] type; @@ -153,7 +153,7 @@ public void setPlacedBy(final Level world, final BlockPos coord, final BlockStat * @param state Block state */ protected void setDefaultFacing(final Level w, final BlockPos coord, final BlockState state) { - if (w.isClientSide) { + if (w.isClientSide()) { return; } final BlockState block = w.getBlockState(coord.north()); diff --git a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java index 1f8d8174..464c6f56 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java +++ b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java @@ -12,11 +12,11 @@ import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; -import com.mojang.blaze3d.vertex.PoseStack; -import com.mojang.blaze3d.systems.RenderSystem; +import net.minecraft.client.renderer.RenderPipelines; +import org.joml.Matrix3x2fStack; import net.minecraft.network.chat.Component; import zone.moddev.mc.basepower.registry.MachineGUIRegistry; @@ -43,9 +43,9 @@ public class SimpleMachineGUI implements ITileEntityGUI { /** * This is the texture resource for drawing the player's inventory. */ - public final static ResourceLocation playerInventoryBox = ResourceLocation.parse(PowerAdvantage.MODID+":"+"textures/gui/container/player_inventory.png"); + public final static Identifier playerInventoryBox = Identifier.parse(PowerAdvantage.MODID+":"+"textures/gui/container/player_inventory.png"); /** Texture resource for the machine's GUI */ - protected final ResourceLocation guiDisplayImage; + protected final Identifier guiDisplayImage; /** Dimensions of the assembled screen, including its label rows. */ protected final int guiWidth, guiHeight; /** List of pixel coordinates for the upper-left corner of each inventory @@ -68,7 +68,7 @@ public class SimpleMachineGUI implements ITileEntityGUI { * is assumed that the index in this list of coordinates corresponds to the * inventory index of the TileEntity's inventory. */ - public SimpleMachineGUI(ResourceLocation guiImage, Integer2D... inventorySlotCoordinates){ + public SimpleMachineGUI(Identifier guiImage, Integer2D... inventorySlotCoordinates){ this.guiDisplayImage = guiImage; this.guiWidth = 176; this.guiHeight = 240; @@ -92,7 +92,7 @@ public SimpleMachineGUI(ResourceLocation guiImage, Integer2D... inventorySlotCoo * inventory index of the TileEntity's inventory. */ public SimpleMachineGUI(String guiImage, Integer2D... inventorySlotCoordinates){ - this(ResourceLocation.parse(guiImage),inventorySlotCoordinates); + this(Identifier.parse(guiImage),inventorySlotCoordinates); } @@ -279,16 +279,16 @@ public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTi this.renderTooltip(graphics, mouseX, mouseY); if (font.width(title.getString()) > width - 8 && mouseY >= topPos + 1 && mouseY < topPos + MACHINE_TOP) { - graphics.renderTooltip(font, title, mouseX, mouseY); + graphics.setTooltipForNextFrame(font, title, mouseX, mouseY); } } @Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { String machineName = fitLabel(title.getString(), width - 8); - graphics.drawString(font, machineName, (imageWidth - font.width(machineName)) / 2, 1, 0xFFFFFF); + graphics.drawString(font, machineName, (imageWidth - font.width(machineName)) / 2, 1, 0xFFFFFFFF); graphics.drawString(font, fitLabel(playerInventoryLabel.getString(), imageWidth - 16), - 8, MACHINE_TOP + MACHINE_HEIGHT, 0xFFFFFF); + 8, MACHINE_TOP + MACHINE_HEIGHT, 0xFFFFFFFF); } private String fitLabel(String text, int availableWidth) { @@ -299,9 +299,6 @@ private String fitLabel(String text, int availableWidth) { @Override protected void renderBg(GuiGraphics graphics, float partialTicks, int mouseX, int mouseY) { this.graphics = graphics; - RenderSystem.enableBlend(); - RenderSystem.defaultBlendFunc(); - RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F); int x = (width - imageWidth) / 2; int y = (height - imageHeight) / 2; final int playerInventoryWidth = 176; @@ -312,7 +309,6 @@ protected void renderBg(GuiGraphics graphics, float partialTicks, int mouseX, in drawGUIDecorations(entity, this, x, y + MACHINE_TOP, getZLevel()); } finally { this.graphics = null; - RenderSystem.disableBlend(); } } @@ -321,14 +317,15 @@ public void blit(int x, int y, int u, int v, int width, int height) { blit(guiDisplayImage, x, y, u, v, width, height); } - public void blit(ResourceLocation texture, int x, int y, int u, int v, int width, int height) { - graphics.blit(texture, x, y, u, v, width, height); + public void blit(Identifier texture, int x, int y, int u, int v, int width, int height) { + graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256); } /** The active render context, available while drawing decorations. */ public GuiGraphics getGraphics() { return graphics; } - public PoseStack getMatrices() { return getGraphics().pose(); } + /** Native GUI transforms are two-dimensional; rotations use radians. */ + public Matrix3x2fStack getMatrices() { return getGraphics().pose(); } /** * Gets the z-coordinate of this GUI element * @return screen depth coordinate diff --git a/src/main/java/cyano/poweradvantage/api/simple/TileEntitySimpleFluidMachine.java b/src/main/java/cyano/poweradvantage/api/simple/TileEntitySimpleFluidMachine.java index d147cf74..79d99345 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/TileEntitySimpleFluidMachine.java +++ b/src/main/java/cyano/poweradvantage/api/simple/TileEntitySimpleFluidMachine.java @@ -80,7 +80,7 @@ public void setFluid(FluidStack fluid) { @Override protected void onContentsChanged() { - if (TileEntitySimpleFluidMachine.this.level != null && !TileEntitySimpleFluidMachine.this.level.isClientSide) { + if (TileEntitySimpleFluidMachine.this.level != null && !TileEntitySimpleFluidMachine.this.level.isClientSide()) { TileEntitySimpleFluidMachine.this.setChanged(); } } @@ -272,18 +272,18 @@ protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider r for(int i = 0; i < inventory.length; i++){ inventory[i] = null; } - final ListTag nbttaglist = tagRoot.getList("Items", 10); + final ListTag nbttaglist = tagRoot.getListOrEmpty("Items"); for (int i = 0; i < nbttaglist.size() && i < inventory.length; ++i) { - final CompoundTag nbttagcompound1 = nbttaglist .getCompound(i); - final byte n = nbttagcompound1.getByte("Slot"); + final CompoundTag nbttagcompound1 = nbttaglist .getCompoundOrEmpty(i); + final byte n = nbttagcompound1.getByteOr("Slot", (byte) 0); if (n >= 0 && n < inventory.length) { ItemStack stack = ItemData.readStack(registries, nbttagcompound1); inventory[n] = stack.isEmpty() ? null : stack; } } } - if (tagRoot .contains("CustomName", 8)) { - this.customName = tagRoot.getString("CustomName"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tagRoot, "CustomName", 8)) { + this.customName = tagRoot.getStringOr("CustomName", ""); } } @@ -301,7 +301,7 @@ protected void saveAdditional(final CompoundTag tagRoot, HolderLookup.Provider r final ListTag nbttaglist = new ListTag(); for (int i = 0; i < inventory.length; ++i) { if (inventory[i] != null && !inventory[i].isEmpty()) { - final CompoundTag nbttagcompound1 = (CompoundTag) inventory[i].save(registries); + final CompoundTag nbttagcompound1 = (CompoundTag) zone.moddev.mc.basepower.util.ItemData.writeStack(inventory[i], registries); nbttagcompound1.putByte("Slot", (byte)i); nbttaglist .add(nbttagcompound1); } @@ -412,14 +412,14 @@ public CompoundTag createDataFieldUpdateTag(){ * getDataFieldArray() array */ public void readDataFieldUpdateTag(CompoundTag tag){ - if (tag .contains("Tank", 10)) { - CompoundTag tankTag = tag .getCompound("Tank"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "Tank", 10)) { + CompoundTag tankTag = tag .getCompoundOrEmpty("Tank"); getTank().readFromNBT(tankTag); - if (tankTag.contains("Empty", 1)) { + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tankTag, "Empty", 1)) { getTank().setFluid(null); } - } else if (tag .contains("[]", 11)) { - int[] newData = tag.getIntArray("[]"); + } else if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "[]", 11)) { + int[] newData = tag.getIntArray("[]").orElseGet(() -> new int[0]); System.arraycopy(newData, 0, this.getDataFieldArray(), 0, Math.min(newData.length, this.getDataFieldArray().length)); this.onDataFieldUpdate(); } @@ -439,7 +439,6 @@ public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithFullMetadata(registries); } - @Override public void handleUpdateTag(CompoundTag tag, HolderLookup.Provider registries) { loadWithComponents(tag, registries); } @@ -447,10 +446,15 @@ public void handleUpdateTag(CompoundTag tag, HolderLookup.Provider registries) { /** * Receives the network packet made by getDescriptionPacket() */ - @Override public void onDataPacket(Connection net, ClientboundBlockEntityDataPacket packet, HolderLookup.Provider registries) { readDataFieldUpdateTag(packet.getTag()); } + + @Override + public void onDataPacket(Connection connection, net.minecraft.world.level.storage.ValueInput input, + HolderLookup.Provider registries) { + readDataFieldUpdateTag(zone.moddev.mc.basepower.util.LegacyNbtFields.read(input)); + } ///// ISidedInventory methods ///// /** @@ -478,7 +482,6 @@ public boolean isEmpty() { /** * executes when a player right-clicks and opens the inventory GUI */ - @Override public void startOpen(Player arg0) { // do nothing @@ -487,7 +490,6 @@ public void startOpen(Player arg0) { /** * executes when a player stops interacting with this machine */ - @Override public void stopOpen(Player arg0) { // do nothing diff --git a/src/main/java/cyano/poweradvantage/api/simple/TileEntitySimplePowerMachine.java b/src/main/java/cyano/poweradvantage/api/simple/TileEntitySimplePowerMachine.java index 3ccb847c..94de9693 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/TileEntitySimplePowerMachine.java +++ b/src/main/java/cyano/poweradvantage/api/simple/TileEntitySimplePowerMachine.java @@ -297,18 +297,18 @@ protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider r ItemStack[] inventory = this.getInventory(); if(inventory != null ){ Arrays.fill(inventory, null); - final ListTag nbttaglist = tagRoot.getList("Items", 10); + final ListTag nbttaglist = tagRoot.getListOrEmpty("Items"); for (int i = 0; i < nbttaglist.size() && i < inventory.length; ++i) { - final CompoundTag nbttagcompound1 = nbttaglist .getCompound(i); - final byte n = nbttagcompound1.getByte("Slot"); + final CompoundTag nbttagcompound1 = nbttaglist .getCompoundOrEmpty(i); + final byte n = nbttagcompound1.getByteOr("Slot", (byte) 0); if (n >= 0 && n < inventory.length) { ItemStack stack = ItemData.readStack(registries, nbttagcompound1); inventory[n] = stack.isEmpty() ? null : stack; } } } - if (tagRoot .contains("CustomName", 8)) { - this.customName = tagRoot.getString("CustomName"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tagRoot, "CustomName", 8)) { + this.customName = tagRoot.getStringOr("CustomName", ""); } } @@ -326,7 +326,7 @@ protected void saveAdditional(final CompoundTag tagRoot, HolderLookup.Provider r final ListTag nbttaglist = new ListTag(); for (int i = 0; i < inventory.length; ++i) { if (inventory[i] != null && !inventory[i].isEmpty()) { - final CompoundTag nbttagcompound1 = (CompoundTag) inventory[i].save(registries); + final CompoundTag nbttagcompound1 = (CompoundTag) zone.moddev.mc.basepower.util.ItemData.writeStack(inventory[i], registries); nbttagcompound1.putByte("Slot", (byte)i); nbttaglist .add(nbttagcompound1); } @@ -418,7 +418,7 @@ public CompoundTag createDataFieldUpdateTag(){ * getDataFieldArray() array */ public void readDataFieldUpdateTag(CompoundTag tag){ - int[] newData = tag.getIntArray("[]"); + int[] newData = tag.getIntArray("[]").orElseGet(() -> new int[0]); System.arraycopy(newData, 0, this.getDataFieldArray(), 0, Math.min(newData.length, this.getDataFieldArray().length)); this.onDataFieldUpdate(); } @@ -435,10 +435,15 @@ public ClientboundBlockEntityDataPacket getUpdatePacket(){ /** * Receives the network packet made by getDescriptionPacket() */ - @Override public void onDataPacket(Connection net, ClientboundBlockEntityDataPacket packet, HolderLookup.Provider registries) { readDataFieldUpdateTag(packet.getTag()); } + + @Override + public void onDataPacket(Connection connection, net.minecraft.world.level.storage.ValueInput input, + HolderLookup.Provider registries) { + readDataFieldUpdateTag(zone.moddev.mc.basepower.util.LegacyNbtFields.read(input)); + } ///// ISidedInventory methods ///// /** @@ -466,7 +471,6 @@ public boolean isEmpty() { /** * executes when a player right-clicks and opens the inventory GUI */ - @Override public void startOpen(Player arg0) { // do nothing @@ -475,7 +479,6 @@ public void startOpen(Player arg0) { /** * executes when a player stops interacting with this machine */ - @Override public void stopOpen(Player arg0) { // do nothing diff --git a/src/main/java/zone/moddev/mc/basepower/PowerAdvantage.java b/src/main/java/zone/moddev/mc/basepower/PowerAdvantage.java index ed47dc11..17717ad1 100644 --- a/src/main/java/zone/moddev/mc/basepower/PowerAdvantage.java +++ b/src/main/java/zone/moddev/mc/basepower/PowerAdvantage.java @@ -17,7 +17,7 @@ import net.minecraftforge.fml.DistExecutor; import net.minecraftforge.fml.ModList; -import net.minecraftforge.eventbus.api.IEventBus; +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; @@ -42,7 +42,7 @@ public final class PowerAdvantage { public static final String MODID = "basepower"; public static final String NAME = "basePower"; - public static final String VERSION = "2.4.14.121011"; + public static final String VERSION = "2.4.15.121111"; public static final Logger LOGGER = LogManager.getLogger(MODID); private static PowerAdvantage instance; @@ -73,17 +73,22 @@ public final class PowerAdvantage { public PowerAdvantage(FMLJavaModLoadingContext context) { instance = this; - IEventBus modBus = context.getModEventBus(); + BusGroup modBus = context.getModBusGroup(); AdvantageLegacyMigrations.registerPowerAdvantage(); zone.moddev.mc.basepower.migration.VanillaRegistryRemaps.register(); - MinecraftForge.EVENT_BUS.addListener(ConduitRegistry.getInstance()::onChunkLoad); - MinecraftForge.EVENT_BUS.addListener(ConduitRegistry.getInstance()::onChunkUnload); - MinecraftForge.EVENT_BUS.addListener(RecipeDiscovery::onPlayerLogin); - MinecraftForge.EVENT_BUS.addListener(zone.moddev.mc.basepower.init.Villages::onTrades); - modBus.addListener( + 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); - modBus.addListener(MachineGUIRegistry::registerTypes); + 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.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); + net.minecraftforge.registries.RegisterEvent.getBus(modBus).addListener(zone.moddev.mc.basepower.init.Entities::register); + net.minecraftforge.registries.RegisterEvent.getBus(modBus).addListener(MachineGUIRegistry::registerTypes); Fluids.init(); ItemGroups.register(modBus); @@ -93,11 +98,12 @@ public PowerAdvantage(FMLJavaModLoadingContext context) { migrateLegacyOreSpawnConfiguration(); migrateLegacyConfiguration(); context.registerConfig(ModConfig.Type.COMMON, PowerAdvantageConfig.SPEC); - modBus.addListener(PowerAdvantageConfig::onConfigLoading); - modBus.addListener(this::commonSetup); + 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); DistExecutor.runWhenOn(Dist.CLIENT, () -> () -> { - modBus.addListener( + net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent.getBus(modBus).addListener( (net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent event) -> event.enqueueWork(() -> { MachineGUIRegistry.registerScreens(); diff --git a/src/main/java/zone/moddev/mc/basepower/blocks/BlockPowerSwitch.java b/src/main/java/zone/moddev/mc/basepower/blocks/BlockPowerSwitch.java index 389764ce..5ba3a0df 100644 --- a/src/main/java/zone/moddev/mc/basepower/blocks/BlockPowerSwitch.java +++ b/src/main/java/zone/moddev/mc/basepower/blocks/BlockPowerSwitch.java @@ -21,7 +21,7 @@ * @author DrCyano * */ -public class BlockPowerSwitch extends Block implements ITypedConduit { +public class BlockPowerSwitch extends cyano.poweradvantage.api.ConduitBlock implements ITypedConduit { /** * Blockstate peroperty. If true, conduct power, if false, don't */ @@ -47,8 +47,9 @@ public BlockPowerSwitch(ConduitType powerType, Block.Properties properties) { * @param powerType Type of power to switch */ public BlockPowerSwitch(ConduitType powerType) { - this(powerType, Properties.of().mapColor(MapColor.STONE).pushReaction(PushReaction.BLOCK) - .forceSolidOn().strength(0.75F)); + this(powerType, zone.moddev.mc.basepower.init.Blocks.properties("fluid_switch", + Properties.of().mapColor(MapColor.STONE).pushReaction(PushReaction.BLOCK) + .forceSolidOn().strength(0.75F))); } /** @@ -163,7 +164,8 @@ public void onNeighborChange(BlockGetter w, BlockPos pos, BlockPos neighbor) { } @Override - public void neighborChanged(BlockState state, Level world, BlockPos pos, Block block, BlockPos fromPos, boolean isMoving) { + public void neighborChanged(BlockState state, Level world, BlockPos pos, Block block, + net.minecraft.world.level.redstone.Orientation orientation, boolean isMoving) { onNeighborChange(world, pos, pos); } 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 8726aa33..c7d78df3 100644 --- a/src/main/java/zone/moddev/mc/basepower/client/ClientOilRenderer.java +++ b/src/main/java/zone/moddev/mc/basepower/client/ClientOilRenderer.java @@ -1,6 +1,6 @@ package zone.moddev.mc.basepower.client; -import net.minecraft.client.renderer.RenderType; +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; @@ -16,7 +16,7 @@ public static void configureRenderLayers() { 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, RenderType.translucent()); + ItemBlockRenderTypes.setRenderLayer(fluid, ChunkSectionLayer.TRANSLUCENT); } } 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 07cfa88c..9e03b597 100644 --- a/src/main/java/zone/moddev/mc/basepower/compat/BaseMetalsCompat.java +++ b/src/main/java/zone/moddev/mc/basepower/compat/BaseMetalsCompat.java @@ -3,7 +3,7 @@ import net.minecraft.world.level.block.Block; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.fml.ModList; import net.minecraftforge.registries.ForgeRegistries; import zone.moddev.mc.basepower.PowerAdvantage; @@ -30,7 +30,7 @@ public static void addCrusherRecipe(Block input, ItemStack output) { } public static ItemStack getSteelPlate() { - Item item = ForgeRegistries.ITEMS.getValue(ResourceLocation.fromNamespaceAndPath("basemetals", "steel_plate")); + Item item = ForgeRegistries.ITEMS.getValue(Identifier.fromNamespaceAndPath("basemetals", "steel_plate")); return item == null ? null : new ItemStack(item); } diff --git a/src/main/java/zone/moddev/mc/basepower/conduitnetwork/ConduitRegistry.java b/src/main/java/zone/moddev/mc/basepower/conduitnetwork/ConduitRegistry.java index de835f2c..af746e99 100644 --- a/src/main/java/zone/moddev/mc/basepower/conduitnetwork/ConduitRegistry.java +++ b/src/main/java/zone/moddev/mc/basepower/conduitnetwork/ConduitRegistry.java @@ -219,7 +219,7 @@ static float distributePower(float availableEnergy, ConduitType powerType, byte * @param type The native energy type of the added conduit block */ public void conduitBlockPlacedEvent(Level w, BlockPos location, ConduitType type) { - if (w.isClientSide) return; // ignore client-side + if (w.isClientSide()) return; // ignore client-side net.minecraft.resources.ResourceKey dimension = w.dimension(); clearIfWorldChanged(w, dimension); BlockPos4D coord = new BlockPos4D(dimension, location); @@ -254,7 +254,7 @@ public void conduitBlockPlacedEvent(Level w, BlockPos location, ConduitType... t * @param types power types whose cached networks must be rebuilt */ public void conduitBlockLoadedEvent(Level w, ConduitType... types) { - if (w.isClientSide) return; + if (w.isClientSide()) return; clearIfWorldChanged(w, w.dimension()); for (ConduitType type : types) { getConduitNetworkManager(type).invalidateAll(); @@ -273,7 +273,7 @@ public void onChunkUnload(ChunkEvent.Unload event) { private void invalidateChunk(ChunkEvent event) { if (!(event.getLevel() instanceof Level world)) return; - if (world.isClientSide) return; + if (world.isClientSide()) return; net.minecraft.resources.ResourceKey dimension = world.dimension(); clearIfWorldChanged(world, dimension); for (ConduitNetworkManager manager : networkManagers.values()) { @@ -289,7 +289,7 @@ private void invalidateChunk(ChunkEvent event) { * @param type The native energy type of the removed conduit block */ public void conduitBlockRemovedEvent(Level w, BlockPos location, ConduitType type) { - if (w.isClientSide) return; // ignore client-side + if (w.isClientSide()) return; // ignore client-side net.minecraft.resources.ResourceKey dimension = w.dimension(); clearIfWorldChanged(w, dimension); BlockPos4D coord = new BlockPos4D(dimension, location); diff --git a/src/main/java/zone/moddev/mc/basepower/config/PowerAdvantageConfig.java b/src/main/java/zone/moddev/mc/basepower/config/PowerAdvantageConfig.java index a7a0981d..5c37648a 100644 --- a/src/main/java/zone/moddev/mc/basepower/config/PowerAdvantageConfig.java +++ b/src/main/java/zone/moddev/mc/basepower/config/PowerAdvantageConfig.java @@ -55,7 +55,7 @@ public final class PowerAdvantageConfig { private PowerAdvantageConfig() {} - public static void onConfigLoading(net.minecraftforge.fml.event.config.ModConfigEvent.Loading event) { + public static void onConfigLoading(net.minecraftforge.fml.event.config.ModConfigEvent event) { if (event.getConfig().getSpec() != SPEC) return; loaded = true; apply(); 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 077b1b25..e50225ac 100644 --- a/src/main/java/zone/moddev/mc/basepower/gui/FilteredFluidTankGUI.java +++ b/src/main/java/zone/moddev/mc/basepower/gui/FilteredFluidTankGUI.java @@ -6,13 +6,13 @@ import cyano.poweradvantage.api.fluid.FluidPoweredEntity; import cyano.poweradvantage.api.simple.SimpleMachineGUI; import zone.moddev.mc.basepower.math.Integer2D; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import cyano.poweradvantage.api.fluid.FluidStack; public class FilteredFluidTankGUI extends SimpleMachineGUI { - public FilteredFluidTankGUI(ResourceLocation image) { + public FilteredFluidTankGUI(Identifier image) { super(image, Integer2D.fromCoordinates(31, 31)); } @@ -37,7 +37,7 @@ public void drawGUIDecorations(Object srcEntity, GUIContainer guiContainer, int @OnlyIn(Dist.CLIENT) public static void drawFluidBar( FluidStack fs, float barHeight, int xPos, int yPos, - ResourceLocation displayImage, GUIContainer guiContainer, int x, int y, float zLevel + Identifier displayImage, GUIContainer guiContainer, int x, int y, float zLevel ) { FluidTankGUI.drawFluidBar(fs, barHeight, xPos, yPos, displayImage, guiContainer, x, y, 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 76ce8027..5dee7214 100644 --- a/src/main/java/zone/moddev/mc/basepower/gui/FluidTankGUI.java +++ b/src/main/java/zone/moddev/mc/basepower/gui/FluidTankGUI.java @@ -3,15 +3,16 @@ import cyano.poweradvantage.api.fluid.FluidPoweredEntity; import cyano.poweradvantage.api.simple.SimpleMachineGUI; import zone.moddev.mc.basepower.math.Integer2D; -import com.mojang.blaze3d.systems.RenderSystem; -import com.mojang.blaze3d.vertex.Tesselator; -import com.mojang.blaze3d.vertex.BufferBuilder; -import com.mojang.blaze3d.vertex.BufferUploader; +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.client.renderer.texture.TextureAtlas; -import com.mojang.blaze3d.vertex.DefaultVertexFormat; -import net.minecraft.resources.ResourceLocation; +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; @@ -21,7 +22,7 @@ public class FluidTankGUI extends SimpleMachineGUI { - public FluidTankGUI(ResourceLocation image) { + public FluidTankGUI(Identifier image) { super(image, new Integer2D[0]); } @@ -46,12 +47,11 @@ public void drawGUIDecorations(Object srcEntity, GUIContainer guiContainer, int @OnlyIn(Dist.CLIENT) public static void drawFluidBar( FluidStack fs, float barHeight, int xPos, int yPos, - ResourceLocation displayImage, GUIContainer guiContainer, int x, int y, float zLevel + Identifier displayImage, GUIContainer guiContainer, int x, int y, float zLevel ) { - barHeight = Float.isFinite(barHeight) ? Math.max(0F, Math.min(1F, barHeight)) : 0F; final int w = 16; final int barSlotHeight = 60; - final int h = (int) (barSlotHeight * barHeight); + final int h = gaugeHeight(barHeight); FluidTankGUI.drawFluidFilledRectangle(guiContainer, fs, x + xPos, y + yPos + barSlotHeight - h, w, h, zLevel); guiContainer.blit(displayImage, x + xPos - 3, y + yPos - 3, 176, 0, 22, 66); @@ -70,38 +70,45 @@ public static void drawFluidFilledRectangle( var rendering = net.minecraftforge.client.extensions.common.IClientFluidTypeExtensions.of(fs.getFluid()); var stillTexture = rendering.getStillTexture(fluidStack); if (stillTexture == null) return; - TextureAtlasSprite sprite = guiContainer.getMinecraft().getTextureAtlas(TextureAtlas.LOCATION_BLOCKS) - .apply(stillTexture); + 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; } - float red = ((color >>> 16) & 255) / 255F; - float green = ((color >>> 8) & 255) / 255F; - float blue = (color & 255) / 255F; - guiContainer.getGraphics().flush(); - var pose = guiContainer.getMatrices().last().pose(); - RenderSystem.setShaderTexture(0, sprite.atlasLocation()); - Tesselator tessellator = Tesselator.getInstance(); - RenderSystem.setShader(net.minecraft.client.renderer.GameRenderer::getPositionTexColorShader); - BufferBuilder vertexbuffer = tessellator.begin(com.mojang.blaze3d.vertex.VertexFormat.Mode.QUADS, - DefaultVertexFormat.POSITION_TEX_COLOR); + 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); + } + + 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 u0 = sprite.getU0(), u1 = sprite.getU((float) tileWidth / tileSize); - float v0 = sprite.getV0(), v1 = sprite.getV((float) tileHeight / tileSize); - vertexbuffer.addVertex(pose, x + dx, y + dy + tileHeight, zLevel).setUv(u0, v1).setColor(red, green, blue, 1F); - vertexbuffer.addVertex(pose, x + dx + tileWidth, y + dy + tileHeight, zLevel).setUv(u1, v1).setColor(red, green, blue, 1F); - vertexbuffer.addVertex(pose, x + dx + tileWidth, y + dy, zLevel).setUv(u1, v0).setColor(red, green, blue, 1F); - vertexbuffer.addVertex(pose, x + dx, y + dy, zLevel).setUv(u0, v0).setColor(red, green, blue, 1F); + 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)); } } - BufferUploader.drawWithShader(vertexbuffer.buildOrThrow()); } } 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 9e11f684..572ea016 100644 --- a/src/main/java/zone/moddev/mc/basepower/gui/StillGUI.java +++ b/src/main/java/zone/moddev/mc/basepower/gui/StillGUI.java @@ -6,13 +6,13 @@ import cyano.poweradvantage.api.simple.SimpleMachineGUI; import zone.moddev.mc.basepower.machines.fluidmachines.StillTileEntity; import zone.moddev.mc.basepower.math.Integer2D; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import cyano.poweradvantage.api.fluid.FluidStack; public class StillGUI extends SimpleMachineGUI { - public StillGUI(ResourceLocation image) { + public StillGUI(Identifier image) { super(image, Integer2D.fromCoordinates(80, 52)); } diff --git a/src/main/java/zone/moddev/mc/basepower/init/Blocks.java b/src/main/java/zone/moddev/mc/basepower/init/Blocks.java index 638ac2ea..4971d84b 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/Blocks.java +++ b/src/main/java/zone/moddev/mc/basepower/init/Blocks.java @@ -16,10 +16,10 @@ import net.minecraft.world.item.Item; import net.minecraft.world.item.BlockItem; import net.minecraft.world.effect.MobEffectInstance; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.registries.ForgeRegistries; import net.minecraftforge.registries.RegisterEvent; -import net.minecraftforge.eventbus.api.SubscribeEvent; + import net.minecraftforge.fml.common.Mod; import zone.moddev.mc.basepower.PowerAdvantage; import zone.moddev.mc.basepower.blocks.BlockFrame; @@ -44,7 +44,6 @@ import zone.moddev.mc.basepower.machines.fluidmachines.modsupport.TerminalFluidPipeBlock; /** Stable block and block-item registry for the 1.13 port. */ -@Mod.EventBusSubscriber(modid = PowerAdvantage.MODID, bus = Mod.EventBusSubscriber.Bus.MOD) public final class Blocks { private static final float STEEL_BLAST_RESISTANCE = 37.5f; private static final Map ALL_BLOCKS = new LinkedHashMap<>(); @@ -89,8 +88,8 @@ public static synchronized void init() { fluid_pipe = add(new FluidPipeBlock(), "fluid_pipe"); fluid_pipe_terminal = add(new TerminalFluidPipeBlock(), "fluid_pipe_terminal"); fluid_switch = add(new BlockPowerSwitch(Fluids.fluidConduit_general), "fluid_switch"); - steel_frame = add(new BlockFrame(GUIBlock.machineProperties(0.75F) - .strength(0.75F, STEEL_BLAST_RESISTANCE)), "steel_frame"); + steel_frame = add(new BlockFrame(properties("steel_frame", GUIBlock.machineProperties(0.75F) + .strength(0.75F, STEEL_BLAST_RESISTANCE))), "steel_frame"); final float hardness = 0.75F; item_conveyor = (GUIBlock) add(new BlockConveyor(hardness), "item_conveyor"); @@ -108,10 +107,10 @@ public static synchronized void init() { infinite_quantum = (GUIBlock) add(new InfiniteEnergyBlock(new ConduitType("quantum")), "infinite_quantum"); refined_oil_block = (LiquidBlock) add(new InteractiveFluidBlock(() -> Fluids.refined_oil, - oilProperties(), true, (world, entity) -> entity.addEffect(new MobEffectInstance(MobEffects.CONFUSION, 200))), + properties("refined_oil", oilProperties()), true, (world, entity) -> entity.addEffect(new MobEffectInstance(MobEffects.NAUSEA, 200))), "refined_oil"); crude_oil_block = (LiquidBlock) add(new InteractiveFluidBlock(() -> Fluids.crude_oil, - oilProperties(), true, (world, entity) -> entity.addEffect(new MobEffectInstance(MobEffects.MOVEMENT_SLOWDOWN, 200, 2))), + properties("crude_oil", oilProperties()), true, (world, entity) -> entity.addEffect(new MobEffectInstance(MobEffects.SLOWNESS, 200, 2))), "crude_oil"); ModSupport.initBlocks(); @@ -120,40 +119,56 @@ public static synchronized void init() { private static GUIBlock filter(String id, Class type, float hardness) { - return (GUIBlock) add(new BlockConveyorFilter(hardness, type), id); + return (GUIBlock) add(new BlockConveyorFilter(id, hardness, type), id); } private static Block.Properties oilProperties() { - return Block.Properties.of().mapColor(MapColor.WATER).replaceable().liquid().noCollission() - .strength(100.0F).dynamicShape().pushReaction(PushReaction.DESTROY).noLootTable().sound(SoundType.EMPTY); + return Block.Properties.of().mapColor(MapColor.WATER).replaceable().liquid().noCollision() + .strength(100.0F).dynamicShape().pushReaction(PushReaction.DESTROY).noLootTable().sound(SoundType.EMPTY).noOcclusion(); + } + + public static Block.Properties properties(String id, Block.Properties properties) { + return properties.setId(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.BLOCK, + Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, id))); } public static Block add(Block block, String id) { ALL_BLOCKS.put(id, block); return block; } - - @SubscribeEvent public static void registerBlocks(RegisterEvent event) { if (!ForgeRegistries.Keys.BLOCKS.equals(event.getRegistryKey())) return; init(); event.register(ForgeRegistries.Keys.BLOCKS, helper -> ALL_BLOCKS.forEach((id, block) -> - helper.register(ResourceLocation.fromNamespaceAndPath(PowerAdvantage.MODID, id), block))); + helper.register(Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, id), block))); } - - @SubscribeEvent public static void registerBlockItems(RegisterEvent event) { if (!ForgeRegistries.Keys.ITEMS.equals(event.getRegistryKey())) return; init(); event.register(ForgeRegistries.Keys.ITEMS, helper -> ALL_BLOCKS.forEach((id, block) -> { - BlockItem item = new BlockItem(block, new Item.Properties()); + BlockItem item = new BlockItem(block, itemProperties(id)) { + @Override + public void appendHoverText(net.minecraft.world.item.ItemStack stack, Item.TooltipContext context, + net.minecraft.world.item.component.TooltipDisplay display, + java.util.function.Consumer tooltip, + net.minecraft.world.item.TooltipFlag flag) { + super.appendHoverText(stack, context, display, tooltip, flag); + if (block instanceof StorageTankBlock tank) tank.appendHoverText(stack, context, display, tooltip, flag); + } + }; if (!"fluid_pipe_terminal".equals(id) && !(block instanceof LiquidBlock)) { ItemGroups.show(item); } - helper.register(ResourceLocation.fromNamespaceAndPath(PowerAdvantage.MODID, id), item); + helper.register(Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, id), item); })); } + static Item.Properties itemProperties(String id) { + return new Item.Properties().setId(net.minecraft.resources.ResourceKey.create( + net.minecraft.core.registries.Registries.ITEM, Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, id))) + .useBlockDescriptionPrefix(); + } + public static Map getModBlockRegistry() { return Collections.unmodifiableMap(ALL_BLOCKS); } diff --git a/src/main/java/zone/moddev/mc/basepower/init/Entities.java b/src/main/java/zone/moddev/mc/basepower/init/Entities.java index b284e167..fb2b559a 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/Entities.java +++ b/src/main/java/zone/moddev/mc/basepower/init/Entities.java @@ -2,10 +2,10 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntityType; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.registries.ForgeRegistries; import net.minecraftforge.registries.RegisterEvent; -import net.minecraftforge.eventbus.api.SubscribeEvent; + import net.minecraftforge.fml.common.Mod; import zone.moddev.mc.basepower.PowerAdvantage; import zone.moddev.mc.basepower.machines.conveyors.TileEntityBlockFilter; @@ -30,7 +30,6 @@ import cyano.poweradvantage.api.modsupport.techreborn.TileEntityTRElectricityConverter; /** Stable tile identities with the native 1.14 valid-block lists. */ -@Mod.EventBusSubscriber(modid = PowerAdvantage.MODID, bus = Mod.EventBusSubscriber.Bus.MOD) public final class Entities { public static BlockEntityType PIPE_TERMINAL; public static BlockEntityType FLUID_DRAIN; @@ -54,8 +53,6 @@ public final class Entities { public static BlockEntityType TR_ELECTRICITY_CONVERTER; private Entities() {} - - @SubscribeEvent public static void register(RegisterEvent event) { if (!ForgeRegistries.Keys.BLOCK_ENTITY_TYPES.equals(event.getRegistryKey())) return; PIPE_TERMINAL = register(event, "tileentity.pipe_terminal", TerminalFluidPipeTileEntity::new, Blocks.fluid_pipe_terminal); @@ -84,8 +81,8 @@ public static void register(RegisterEvent event) { private static BlockEntityType register( RegisterEvent event, String id, BlockEntityType.BlockEntitySupplier factory, net.minecraft.world.level.block.Block... validBlocks) { - BlockEntityType type = BlockEntityType.Builder.of(factory, validBlocks).build(null); - event.register(ForgeRegistries.Keys.BLOCK_ENTITY_TYPES, ResourceLocation.fromNamespaceAndPath(PowerAdvantage.MODID, id), () -> type); + BlockEntityType type = new BlockEntityType<>(factory, java.util.Set.of(validBlocks)); + event.register(ForgeRegistries.Keys.BLOCK_ENTITY_TYPES, Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, id), () -> type); return type; } } 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 9a063ca1..4b2d8504 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/Fluids.java +++ b/src/main/java/zone/moddev/mc/basepower/init/Fluids.java @@ -13,7 +13,7 @@ import net.minecraft.world.item.Item; import net.minecraft.world.level.block.state.StateDefinition; import net.minecraft.core.Direction; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.BlockPos; import net.minecraft.core.Registry; import net.minecraft.world.level.LevelAccessor; @@ -21,7 +21,7 @@ import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.client.extensions.common.IClientFluidTypeExtensions; -import net.minecraftforge.eventbus.api.SubscribeEvent; + import net.minecraftforge.fluids.FluidType; import net.minecraftforge.fluids.ForgeFlowingFluid; import net.minecraftforge.fml.common.Mod; @@ -30,7 +30,6 @@ import zone.moddev.mc.basepower.PowerAdvantage; /** Native Forge fluids and their persistent PowerAdvantage conduit identities. */ -@Mod.EventBusSubscriber(modid = PowerAdvantage.MODID, bus = Mod.EventBusSubscriber.Bus.MOD) public final class Fluids { public static final ConduitType fluidConduit_general = new ConduitType("fluid"); private static final Map FLUID_TYPES = new IdentityHashMap<>(); @@ -54,8 +53,6 @@ public static synchronized void init() { fluidConduit_lava = registerNewFluid(net.minecraft.world.level.material.Fluids.LAVA); initialized = true; } - - @SubscribeEvent public static void registerFluids(RegisterEvent event) { if (ForgeRegistries.Keys.FLUID_TYPES.equals(event.getRegistryKey())) { crudeOilType = oilType("crude_oil", 850, 6000); @@ -84,8 +81,8 @@ static synchronized void prepareOilFluids() { refined_oil = new PowerFluid.Source(Kind.REFINED); } - private static ResourceLocation id(String name) { - return ResourceLocation.fromNamespaceAndPath(PowerAdvantage.MODID, name); + private static Identifier id(String name) { + return Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, name); } private static FluidType oilType(String name, int density, int viscosity) { @@ -93,15 +90,15 @@ private static FluidType oilType(String name, int density, int viscosity) { .density(density).viscosity(viscosity).temperature(300)) { @Override public void initializeClient(Consumer consumer) { consumer.accept(new IClientFluidTypeExtensions() { - @Override public ResourceLocation getStillTexture() { return id("blocks/" + name + "_still"); } - @Override public ResourceLocation getFlowingTexture() { return id("blocks/" + name + "_flow"); } + @Override public Identifier getStillTexture() { return id("blocks/" + name + "_still"); } + @Override public Identifier getFlowingTexture() { return id("blocks/" + name + "_flow"); } }); } }; } public static ConduitType registerNewFluid(Fluid fluid) { - ResourceLocation id = ForgeRegistries.FLUIDS.getKey(fluid); + Identifier id = ForgeRegistries.FLUIDS.getKey(fluid); ConduitType type = new ConduitType(id == null ? "unknown_fluid" : id.toString()); FLUID_TYPES.put(fluid, type); return type; @@ -125,16 +122,16 @@ public static Fluid conduitTypeToFluid(ConduitType type) { public static Fluid byName(String name) { if (name == null || name.trim().isEmpty()) return null; - ResourceLocation id = name.indexOf(':') >= 0 ? ResourceLocation.parse(name) - : ResourceLocation.fromNamespaceAndPath(PowerAdvantage.MODID, name); + Identifier id = name.indexOf(':') >= 0 ? Identifier.parse(name) + : Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, name); Fluid fluid = registeredFluid(id); if (fluid == null && name.indexOf(':') < 0) { - fluid = registeredFluid(ResourceLocation.fromNamespaceAndPath("minecraft", name)); + fluid = registeredFluid(Identifier.fromNamespaceAndPath("minecraft", name)); } return fluid; } - private static Fluid registeredFluid(ResourceLocation id) { + private static Fluid registeredFluid(Identifier id) { // Forge's defaulted fluid registry returns EMPTY, not null, for unknown names. // Do not let a missing basepower:water hide the legacy vanilla fallback. return net.minecraftforge.registries.ForgeRegistries.FLUIDS.containsKey(id) diff --git a/src/main/java/zone/moddev/mc/basepower/init/GUI.java b/src/main/java/zone/moddev/mc/basepower/init/GUI.java index 92498b9d..0e94ffbb 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/GUI.java +++ b/src/main/java/zone/moddev/mc/basepower/init/GUI.java @@ -1,6 +1,6 @@ package zone.moddev.mc.basepower.init; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import zone.moddev.mc.basepower.PowerAdvantage; import cyano.poweradvantage.api.simple.SimpleMachineGUI; import zone.moddev.mc.basepower.gui.*; @@ -17,15 +17,15 @@ public static void init() { Blocks.init(); - Blocks.fluid_drain.setGuiID(MachineGUIRegistry.addGUI(new FluidTankGUI(ResourceLocation.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/fluid_drain_gui.png"))), PowerAdvantage.getInstance()); + Blocks.fluid_drain.setGuiID(MachineGUIRegistry.addGUI(new FluidTankGUI(Identifier.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/fluid_drain_gui.png"))), PowerAdvantage.getInstance()); - Blocks.fluid_discharge.setGuiID(MachineGUIRegistry.addGUI(new FluidTankGUI(ResourceLocation.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/fluid_discharge_gui.png"))), PowerAdvantage.getInstance()); + Blocks.fluid_discharge.setGuiID(MachineGUIRegistry.addGUI(new FluidTankGUI(Identifier.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/fluid_discharge_gui.png"))), PowerAdvantage.getInstance()); - Blocks.storage_tank.setGuiID(MachineGUIRegistry.addGUI(new FluidTankGUI(ResourceLocation.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/fluid_storage_tank_gui.png"))), PowerAdvantage.getInstance()); + Blocks.storage_tank.setGuiID(MachineGUIRegistry.addGUI(new FluidTankGUI(Identifier.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/fluid_storage_tank_gui.png"))), PowerAdvantage.getInstance()); - Blocks.metal_storage_tank.setGuiID(MachineGUIRegistry.addGUI(new FilteredFluidTankGUI(ResourceLocation.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/fluid_metal_tank_gui.png"))), PowerAdvantage.getInstance()); + Blocks.metal_storage_tank.setGuiID(MachineGUIRegistry.addGUI(new FilteredFluidTankGUI(Identifier.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/fluid_metal_tank_gui.png"))), PowerAdvantage.getInstance()); - Blocks.still.setGuiID(MachineGUIRegistry.addGUI(new StillGUI(ResourceLocation.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/still_gui.png"))), PowerAdvantage.getInstance()); + Blocks.still.setGuiID(MachineGUIRegistry.addGUI(new StillGUI(Identifier.parse(PowerAdvantage.MODID + ":" + "textures/gui/container/still_gui.png"))), PowerAdvantage.getInstance()); Blocks.item_conveyor.setGuiID(MachineGUIRegistry.addGUI(new SimpleMachineGUI( PowerAdvantage.MODID + ":textures/gui/container/item_conveyor.png", diff --git a/src/main/java/zone/moddev/mc/basepower/init/ItemGroups.java b/src/main/java/zone/moddev/mc/basepower/init/ItemGroups.java index 74d52557..7c4fc242 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/ItemGroups.java +++ b/src/main/java/zone/moddev/mc/basepower/init/ItemGroups.java @@ -9,7 +9,7 @@ import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.Item; import net.minecraft.world.item.BlockItem; -import net.minecraftforge.eventbus.api.IEventBus; +import net.minecraftforge.eventbus.api.bus.BusGroup; import net.minecraftforge.registries.DeferredRegister; import net.minecraftforge.registries.ForgeRegistries; import zone.moddev.mc.basepower.PowerAdvantage; @@ -41,7 +41,7 @@ public static T show(T item) { return item; } - public static void register(IEventBus bus) { + public static void register(BusGroup bus) { TABS.register(bus); } diff --git a/src/main/java/zone/moddev/mc/basepower/init/Items.java b/src/main/java/zone/moddev/mc/basepower/init/Items.java index 4ae8a609..f43ce141 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/Items.java +++ b/src/main/java/zone/moddev/mc/basepower/init/Items.java @@ -6,16 +6,15 @@ import net.minecraft.world.item.Item; import net.minecraft.world.item.BucketItem; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.registries.ForgeRegistries; import net.minecraftforge.registries.RegisterEvent; -import net.minecraftforge.eventbus.api.SubscribeEvent; + import net.minecraftforge.fml.common.Mod; import zone.moddev.mc.basepower.PowerAdvantage; import zone.moddev.mc.basepower.items.RotationTool; /** Stable item registry for PowerAdvantage 1.13. */ -@Mod.EventBusSubscriber(modid = PowerAdvantage.MODID, bus = Mod.EventBusSubscriber.Bus.MOD) public final class Items { public static Item starch; public static Item bioplastic_ingot; @@ -31,34 +30,33 @@ private Items() {} public static synchronized void init() { if (initialized) return; - starch = add("starch", new Item(properties())); - bioplastic_ingot = add("bioplastic_ingot", new Item(properties())); - sprocket = add("sprocket", new Item(properties())); - rotator_tool = add("rotator_tool", new RotationTool(properties().stacksTo(1))); + starch = add("starch", new Item(properties("starch"))); + bioplastic_ingot = add("bioplastic_ingot", new Item(properties("bioplastic_ingot"))); + sprocket = add("sprocket", new Item(properties("sprocket"))); + rotator_tool = add("rotator_tool", new RotationTool(properties("rotator_tool").stacksTo(1))); Fluids.prepareOilFluids(); // Forge 50's vanilla bucket placement still reads the eager fluid field. crude_oil_bucket = add("crude_oil_bucket", new BucketItem(Fluids.crude_oil, - properties().stacksTo(1).craftRemainder(net.minecraft.world.item.Items.BUCKET))); + properties("crude_oil_bucket").stacksTo(1).craftRemainder(net.minecraft.world.item.Items.BUCKET))); refined_oil_bucket = add("refined_oil_bucket", new BucketItem(Fluids.refined_oil, - properties().stacksTo(1).craftRemainder(net.minecraft.world.item.Items.BUCKET))); + properties("refined_oil_bucket").stacksTo(1).craftRemainder(net.minecraft.world.item.Items.BUCKET))); initialized = true; } - private static Item.Properties properties() { - return new Item.Properties(); + private static Item.Properties properties(String id) { + return new Item.Properties().setId(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.ITEM, + Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, id))); } private static Item add(String id, Item item) { allItems.put(id, item); return item; } - - @SubscribeEvent public static void register(RegisterEvent event) { if (!ForgeRegistries.Keys.ITEMS.equals(event.getRegistryKey())) return; init(); event.register(ForgeRegistries.Keys.ITEMS, helper -> allItems.forEach((id, item) -> - helper.register(ResourceLocation.fromNamespaceAndPath(PowerAdvantage.MODID, id), ItemGroups.show(item)))); + helper.register(Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, id), ItemGroups.show(item)))); } public static Item getItemByName(String id) { return allItems.get(id); } diff --git a/src/main/java/zone/moddev/mc/basepower/init/ModSupport.java b/src/main/java/zone/moddev/mc/basepower/init/ModSupport.java index 8036a17c..9209ac76 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/ModSupport.java +++ b/src/main/java/zone/moddev/mc/basepower/init/ModSupport.java @@ -28,13 +28,13 @@ private ModSupport() {} public static synchronized void initBlocks() { if (initialized) return; float hardness = 0.75F; - converter_rf_steam = Blocks.add(new BlockRFConverter(GUIBlock.machineProperties(hardness), new ConduitType("steam"), + converter_rf_steam = Blocks.add(new BlockRFConverter(Blocks.properties("converter_rf_steam", GUIBlock.machineProperties(hardness)), new ConduitType("steam"), TileEntityRFSteamConverter.class), "converter_rf_steam"); - converter_rf_electricity = Blocks.add(new BlockRFConverter(GUIBlock.machineProperties(hardness), new ConduitType("electricity"), + converter_rf_electricity = Blocks.add(new BlockRFConverter(Blocks.properties("converter_rf_electricity", GUIBlock.machineProperties(hardness)), new ConduitType("electricity"), TileEntityRFElectricityConverter.class), "converter_rf_electricity"); - converter_rf_quantum = Blocks.add(new BlockRFConverter(GUIBlock.machineProperties(hardness), new ConduitType("quantum"), + converter_rf_quantum = Blocks.add(new BlockRFConverter(Blocks.properties("converter_rf_quantum", GUIBlock.machineProperties(hardness)), new ConduitType("quantum"), TileEntityRFQuantumConverter.class), "converter_rf_quantum"); - converter_tr_electricity = Blocks.add(new BlockTRConverter(GUIBlock.machineProperties(hardness), new ConduitType("electricity"), + converter_tr_electricity = Blocks.add(new BlockTRConverter(Blocks.properties("converter_tr_electricity", GUIBlock.machineProperties(hardness)), new ConduitType("electricity"), TileEntityTRElectricityConverter.class), "converter_tr_electricity"); initialized = true; } diff --git a/src/main/java/zone/moddev/mc/basepower/init/Recipes.java b/src/main/java/zone/moddev/mc/basepower/init/Recipes.java index dc0e094a..5d6ef695 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/Recipes.java +++ b/src/main/java/zone/moddev/mc/basepower/init/Recipes.java @@ -3,7 +3,7 @@ import zone.moddev.mc.basepower.PowerAdvantage; import cyano.poweradvantage.api.FluidCategoryRegistry; import net.minecraft.world.level.material.Fluid; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; import zone.moddev.mc.basepower.registry.still.recipe.DistillationRecipeRegistry; @@ -41,12 +41,12 @@ public static void initDistillationRecipes(String[] distillRecipes) { private static void initCrudeOilAliasDistillationRecipes() { Fluid refinedOil = Fluids.refined_oil; if (refinedOil == null) return; - ResourceLocation outputId = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(refinedOil); + Identifier outputId = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(refinedOil); if (outputId == null) return; for (Fluid input : FluidCategoryRegistry.getRegisteredFluids(FluidCategoryRegistry.CRUDE_OIL)) { if (DistillationRecipeRegistry.getInstance().getDistillationRecipeForFluid(input) != null) continue; - ResourceLocation inputId = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(input); + Identifier inputId = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(input); if (inputId != null) { DistillationRecipeRegistry.addDistillationRecipe(inputId.toString(), 2, outputId.toString(), 1); } diff --git a/src/main/java/zone/moddev/mc/basepower/init/Villages.java b/src/main/java/zone/moddev/mc/basepower/init/Villages.java index 499519f9..f6040b3a 100644 --- a/src/main/java/zone/moddev/mc/basepower/init/Villages.java +++ b/src/main/java/zone/moddev/mc/basepower/init/Villages.java @@ -1,6 +1,6 @@ package zone.moddev.mc.basepower.init; -import net.minecraft.world.entity.npc.VillagerProfession; +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; @@ -12,11 +12,11 @@ private Villages() {} public static void onTrades(VillagerTradesEvent event) { if (event.getType() == VillagerProfession.TOOLSMITH) { - event.getTrades().get(1).add((villager, random) -> new MerchantOffer( + 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((villager, random) -> new MerchantOffer( + 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/conveyors/BlockConveyor.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/BlockConveyor.java index 6fafbdd1..5f9e3383 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/BlockConveyor.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/BlockConveyor.java @@ -5,7 +5,7 @@ import net.minecraft.world.level.block.SoundType; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.item.context.BlockPlaceContext; -import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.level.block.state.properties.EnumProperty; import net.minecraft.world.level.block.state.StateDefinition; import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.block.entity.BlockEntity; @@ -18,10 +18,10 @@ /** Six-facing conveyor block; legacy metadata remains the facing index. */ public class BlockConveyor extends GUIBlock { - public static final DirectionProperty FACING = BlockStateProperties.FACING; + public static final EnumProperty FACING = BlockStateProperties.FACING; public BlockConveyor(float hardness) { - super(machineProperties(hardness).sound(SoundType.METAL)); + super(zone.moddev.mc.basepower.init.Blocks.properties("item_conveyor", machineProperties(hardness).sound(SoundType.METAL))); registerDefaultState(getStateDefinition().any().setValue(FACING, Direction.NORTH)); } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/BlockConveyorFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/BlockConveyorFilter.java index 63fb0204..f7f8514b 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/BlockConveyorFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/BlockConveyorFilter.java @@ -6,7 +6,7 @@ import net.minecraft.world.level.block.SoundType; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.item.context.BlockPlaceContext; -import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.level.block.state.properties.EnumProperty; import net.minecraft.world.level.block.state.StateDefinition; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.core.Direction; @@ -19,12 +19,12 @@ /** Horizontal conveyor filter block retaining its legacy metadata codec. */ public class BlockConveyorFilter extends GUIBlock { - public static final DirectionProperty FACING = HorizontalDirectionalBlock.FACING; + public static final EnumProperty FACING = HorizontalDirectionalBlock.FACING; private final Class tileEntityClass; - public BlockConveyorFilter(float hardness, + public BlockConveyorFilter(String id, float hardness, Class tileEntityClass) { - super(machineProperties(hardness).sound(SoundType.METAL)); + super(zone.moddev.mc.basepower.init.Blocks.properties(id, machineProperties(hardness).sound(SoundType.METAL))); this.tileEntityClass = tileEntityClass; registerDefaultState(getStateDefinition().any().setValue(FACING, Direction.NORTH)); } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityBlockFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityBlockFilter.java index 06a2055b..c1c34e9a 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityBlockFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityBlockFilter.java @@ -2,10 +2,10 @@ import net.minecraft.world.item.BlockItem; import net.minecraft.world.item.ItemStack; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityBlockFilter extends TileEntityConveyorFilter { - public TileEntityBlockFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(Entities.FILTER_BLOCK, pos, state, 1); } + public TileEntityBlockFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(zone.moddev.mc.basepower.init.Entities.FILTER_BLOCK, pos, state, 1); } @Override public boolean matchesFilter(ItemStack item) { diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityConveyor.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityConveyor.java index 54eaa087..22842f07 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityConveyor.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityConveyor.java @@ -25,7 +25,7 @@ import java.util.Arrays; import java.util.List; import java.util.Random; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityConveyor extends BlockEntity implements WorldlyContainer, net.minecraft.world.Nameable { private final zone.moddev.mc.basepower.util.MachineCapabilities machineCapabilities = @@ -66,7 +66,7 @@ public class TileEntityConveyor extends BlockEntity implements WorldlyContainer, public TileEntityConveyor(BlockPos pos, BlockState state){ - this(Entities.CONVEYOR, pos, state, 1); + this(zone.moddev.mc.basepower.init.Entities.CONVEYOR, pos, state, 1); } protected TileEntityConveyor(BlockEntityType type, BlockPos pos, BlockState state, int inventorySize){ @@ -77,7 +77,7 @@ protected TileEntityConveyor(BlockEntityType type, BlockPos pos, BlockState s public void tick() { Level w = getLevel(); - if(w.isClientSide) return; + if(w.isClientSide()) return; if(transferCooldown > 0) transferCooldown--; if(transferCooldown == 0){ // do tick update @@ -110,7 +110,7 @@ public void tick() { upstreamBlock.getX()+1, upstreamBlock.getY()+1, upstreamBlock.getZ()+1)); if(!list.isEmpty()){ ItemEntity e = list.get(0); - if(!e.hasImpulse){ + if(!e.needsSync){ ItemStack entityStack = e.getItem(); if(canInsertItemInto(entityStack,this,myDir)){ ItemStack newItem = entityStack.copy(); @@ -241,23 +241,27 @@ protected ItemStack[] getInventory(){ * @param tagRoot The root of the NBT */ @Override + protected void loadAdditional(net.minecraft.world.level.storage.ValueInput input) { + super.loadAdditional(input); + loadAdditional(zone.moddev.mc.basepower.util.LegacyNbtFields.read(input), input.lookup()); + } + protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider registries) { - super.loadAdditional(tagRoot, registries); ItemStack[] inventory = this.getInventory(); if(inventory != null ){ Arrays.fill(inventory, null); - final ListTag nbttaglist = tagRoot.getList("Items", 10); + final ListTag nbttaglist = tagRoot.getListOrEmpty("Items"); for (int i = 0; i < nbttaglist.size() && i < inventory.length; ++i) { - final CompoundTag nbttagcompound1 = nbttaglist .getCompound(i); - final byte n = nbttagcompound1.getByte("Slot"); + final CompoundTag nbttagcompound1 = nbttaglist .getCompoundOrEmpty(i); + final byte n = nbttagcompound1.getByteOr("Slot", (byte) 0); if (n >= 0 && n < inventory.length) { ItemStack stack = ItemData.readStack(registries, nbttagcompound1); inventory[n] = stack.isEmpty() ? null : stack; } } } - if (tagRoot .contains("CustomName", 8)) { - this.customName = tagRoot.getString("CustomName"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tagRoot, "CustomName", 8)) { + this.customName = tagRoot.getStringOr("CustomName", ""); } } @@ -268,14 +272,22 @@ protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider r * @param tagRoot The root of the NBT */ @Override + protected void saveAdditional(net.minecraft.world.level.storage.ValueOutput output) { + super.saveAdditional(output); + CompoundTag tag = new CompoundTag(); + saveAdditional(tag, zone.moddev.mc.basepower.util.LegacyNbtFields.registries(output, + level != null ? level.registryAccess() : net.minecraft.core.RegistryAccess.fromRegistryOfRegistries( + net.minecraft.core.registries.BuiltInRegistries.REGISTRY))); + zone.moddev.mc.basepower.util.LegacyNbtFields.write(output, tag); + } + protected void saveAdditional(final CompoundTag tagRoot, HolderLookup.Provider registries) { - super.saveAdditional(tagRoot, registries); ItemStack[] inventory = this.getInventory(); if(inventory != null ){ final ListTag nbttaglist = new ListTag(); for (int i = 0; i < inventory.length; ++i) { if (inventory[i] != null && !inventory[i].isEmpty()) { - final CompoundTag nbttagcompound1 = (CompoundTag) inventory[i].save(registries); + final CompoundTag nbttagcompound1 = (CompoundTag) zone.moddev.mc.basepower.util.ItemData.writeStack(inventory[i], registries); nbttagcompound1.putByte("Slot", (byte)i); nbttaglist .add(nbttagcompound1); } @@ -313,7 +325,6 @@ public boolean isEmpty() { return true; } - @Override public void stopOpen(Player p) { // do nothing } @@ -382,7 +393,6 @@ public boolean stillValid(final Player p_isUseableByPlayer_1_) { (double)this.worldPosition.getZ() + 0.5) <= 64.0; } - @Override public void startOpen(Player arg0) { // do nothing } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityConveyorFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityConveyorFilter.java index 48da26d1..116aebde 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityConveyorFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityConveyorFilter.java @@ -13,7 +13,7 @@ import net.minecraft.core.BlockPos; import net.minecraft.world.level.Level; import zone.moddev.mc.basepower.PowerAdvantage; -import zone.moddev.mc.basepower.init.Entities; + public abstract class TileEntityConveyorFilter extends TileEntityConveyor { @@ -26,7 +26,7 @@ protected TileEntityConveyorFilter(BlockEntityType type, BlockPos pos, BlockS @Override public void tick() { Level w = getLevel(); - if (w.isClientSide) return; + if (w.isClientSide()) return; if (transferCooldown > 0) transferCooldown--; if (transferCooldown == 0) { // do tick update diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityFoodFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityFoodFilter.java index 735ef364..545bc44a 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityFoodFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityFoodFilter.java @@ -1,10 +1,10 @@ package zone.moddev.mc.basepower.machines.conveyors; import net.minecraft.world.item.ItemStack; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityFoodFilter extends TileEntityConveyorFilter { - public TileEntityFoodFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(Entities.FILTER_FOOD, pos, state, 1); } + public TileEntityFoodFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(zone.moddev.mc.basepower.init.Entities.FILTER_FOOD, pos, state, 1); } @Override public boolean matchesFilter(ItemStack item) { diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityFuelFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityFuelFilter.java index 76d9d8c4..bf53a708 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityFuelFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityFuelFilter.java @@ -3,15 +3,15 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityFuelFilter extends TileEntityConveyorFilter { - public TileEntityFuelFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(Entities.FILTER_FUEL, pos, state, 1); } + public TileEntityFuelFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(zone.moddev.mc.basepower.init.Entities.FILTER_FUEL, pos, state, 1); } @Override public boolean matchesFilter(ItemStack item) { if (item == null) return false; - return AbstractFurnaceBlockEntity.isFuel(item); + return zone.moddev.mc.basepower.registry.FuelRegistry.getActualBurntimeForItem(item) > 0; } } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityInventoryFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityInventoryFilter.java index bb5e5617..072f3f65 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityInventoryFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityInventoryFilter.java @@ -1,7 +1,7 @@ package zone.moddev.mc.basepower.machines.conveyors; import net.minecraft.world.item.ItemStack; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityInventoryFilter extends TileEntityConveyorFilter { @@ -9,7 +9,7 @@ public class TileEntityInventoryFilter extends TileEntityConveyorFilter { public static final int NUM_FILTER_SLOTS = 9; public TileEntityInventoryFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.FILTER_INVENTORY, pos, state, NUM_FILTER_SLOTS + 1); + super(zone.moddev.mc.basepower.init.Entities.FILTER_INVENTORY, pos, state, NUM_FILTER_SLOTS + 1); } @Override diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityOreFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityOreFilter.java index 6e0290a0..30678db5 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityOreFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityOreFilter.java @@ -2,10 +2,10 @@ import net.minecraft.world.item.ItemStack; import net.minecraftforge.common.Tags; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityOreFilter extends TileEntityConveyorFilter { - public TileEntityOreFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(Entities.FILTER_ORE, pos, state, 1); } + public TileEntityOreFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(zone.moddev.mc.basepower.init.Entities.FILTER_ORE, pos, state, 1); } @Override public boolean matchesFilter(ItemStack item) { diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityOverflowFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityOverflowFilter.java index 96f2060d..5db4d52f 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityOverflowFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityOverflowFilter.java @@ -5,10 +5,10 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.core.Direction; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityOverflowFilter extends TileEntityConveyorFilter { - public TileEntityOverflowFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(Entities.FILTER_OVERFLOW, pos, state, 1); } + public TileEntityOverflowFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(zone.moddev.mc.basepower.init.Entities.FILTER_OVERFLOW, pos, state, 1); } @Override public boolean matchesFilter(ItemStack item) { diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityPlantFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityPlantFilter.java index 1e573305..d74fc11b 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityPlantFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntityPlantFilter.java @@ -5,10 +5,10 @@ import net.minecraft.world.item.BlockItem; import net.minecraft.world.item.ItemStack; import net.minecraftforge.common.IPlantable; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntityPlantFilter extends TileEntityConveyorFilter { - public TileEntityPlantFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(Entities.FILTER_PLANT, pos, state, 1); } + public TileEntityPlantFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(zone.moddev.mc.basepower.init.Entities.FILTER_PLANT, pos, state, 1); } @Override public boolean matchesFilter(ItemStack item) { diff --git a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntitySmeltableFilter.java b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntitySmeltableFilter.java index b6302870..71194f8d 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntitySmeltableFilter.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/conveyors/TileEntitySmeltableFilter.java @@ -4,16 +4,17 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.network.chat.Component; import net.minecraft.world.item.crafting.RecipeType; -import zone.moddev.mc.basepower.init.Entities; + public class TileEntitySmeltableFilter extends TileEntityConveyorFilter { - public TileEntitySmeltableFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(Entities.FILTER_SMELT, pos, state, 1); } + public TileEntitySmeltableFilter(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(zone.moddev.mc.basepower.init.Entities.FILTER_SMELT, pos, state, 1); } @Override public boolean matchesFilter(ItemStack item) { if (item == null || item.isEmpty() || level == null) return false; SingleRecipeInput probe = new SingleRecipeInput(item.copy()); - return level.getRecipeManager().getRecipeFor(RecipeType.SMELTING, probe, level).isPresent(); + return level instanceof net.minecraft.server.level.ServerLevel server + && server.recipeAccess().getRecipeFor(RecipeType.SMELTING, probe, server).isPresent(); } } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/creative/InfiniteEnergyBlock.java b/src/main/java/zone/moddev/mc/basepower/machines/creative/InfiniteEnergyBlock.java index 704ad988..04394262 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/creative/InfiniteEnergyBlock.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/creative/InfiniteEnergyBlock.java @@ -12,8 +12,9 @@ public class InfiniteEnergyBlock extends BlockSimplePowerMachine { public InfiniteEnergyBlock(ConduitType energyType) { - super(Properties.of().mapColor(MapColor.METAL).forceSolidOn().strength(0.75F) - .noOcclusion().dynamicShape(), energyType); + super(zone.moddev.mc.basepower.init.Blocks.properties("infinite_" + energyType, + Properties.of().mapColor(MapColor.METAL).forceSolidOn().strength(0.75F) + .noOcclusion().dynamicShape()), energyType); } @Override diff --git a/src/main/java/zone/moddev/mc/basepower/machines/creative/InfiniteEnergyTileEntity.java b/src/main/java/zone/moddev/mc/basepower/machines/creative/InfiniteEnergyTileEntity.java index 21401aa0..bd347a37 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/creative/InfiniteEnergyTileEntity.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/creative/InfiniteEnergyTileEntity.java @@ -5,7 +5,7 @@ import cyano.poweradvantage.api.simple.TileEntitySimplePowerMachine; import net.minecraft.world.item.ItemStack; import net.minecraft.nbt.CompoundTag; -import zone.moddev.mc.basepower.init.Entities; + public class InfiniteEnergyTileEntity extends TileEntitySimplePowerMachine { @@ -14,12 +14,12 @@ public class InfiniteEnergyTileEntity extends TileEntitySimplePowerMachine { private final ConduitType[] type = new ConduitType[1]; public InfiniteEnergyTileEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state, ConduitType energyType) { - super(Entities.INFINITE_ENERGY, pos, state, energyType, 1000f, "tile.basepower.infinite_energy_source.name"); + super(zone.moddev.mc.basepower.init.Entities.INFINITE_ENERGY, pos, state, energyType, 1000f, "tile.basepower.infinite_energy_source.name"); this.type[0] = energyType; } public InfiniteEnergyTileEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.INFINITE_ENERGY, pos, state, new ConduitType("power"), 1000f, "tile.basepower.infinite_energy_source.name"); + super(zone.moddev.mc.basepower.init.Entities.INFINITE_ENERGY, pos, state, new ConduitType("power"), 1000f, "tile.basepower.infinite_energy_source.name"); this.type[0] = new ConduitType("power"); } @@ -98,8 +98,8 @@ protected void saveAdditional(final CompoundTag tagRoot, HolderLookup.Provider r @Override protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider registries) { super.loadAdditional(tagRoot, registries); - if (tagRoot.contains("InfType", 8)) { - this.type[0] = new ConduitType(tagRoot.getString("InfType")); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tagRoot, "InfType", 8)) { + this.type[0] = new ConduitType(tagRoot.getStringOr("InfType", "")); } } } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDischargeBlock.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDischargeBlock.java index fb806adf..4b393435 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDischargeBlock.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDischargeBlock.java @@ -15,7 +15,7 @@ public class FluidDischargeBlock extends BlockSimpleFluidMachine { public FluidDischargeBlock() { - super(machineProperties(3F)); + super(zone.moddev.mc.basepower.init.Blocks.properties("fluid_discharge", machineProperties(3F))); } @Override 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 be5965ad..0bc4990f 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 @@ -15,7 +15,7 @@ import net.minecraft.core.Direction; import net.minecraft.core.BlockPos; import net.minecraft.world.level.Level; -import zone.moddev.mc.basepower.init.Entities; + import zone.moddev.mc.basepower.util.FluidHandlerHelper; /** Places stored fluid into the world or into a tank below. */ @@ -23,7 +23,7 @@ public class FluidDischargeTileEntity extends TileEntitySimpleFluidMachine { private int oldLevel = -1; public FluidDischargeTileEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.FLUID_DISCHARGE, pos, state, FluidTank.BUCKET_VOLUME, "tile.basepower.fluid_discharge.name"); + super(zone.moddev.mc.basepower.init.Entities.FLUID_DISCHARGE, pos, state, FluidTank.BUCKET_VOLUME, "tile.basepower.fluid_discharge.name"); } @Override diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDrainBlock.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDrainBlock.java index baff050c..59c7b2f8 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDrainBlock.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidDrainBlock.java @@ -13,7 +13,7 @@ public class FluidDrainBlock extends BlockSimpleFluidMachine { public FluidDrainBlock() { - super(machineProperties(3F)); + super(zone.moddev.mc.basepower.init.Blocks.properties("fluid_drain", machineProperties(3F))); } @Override 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 5c92f517..4b6a9554 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 @@ -16,13 +16,13 @@ import net.minecraft.core.Direction; import net.minecraft.core.BlockPos; import net.minecraft.world.level.Level; -import zone.moddev.mc.basepower.init.Entities; + import zone.moddev.mc.basepower.util.FluidHandlerHelper; /** Pulls source blocks or adjacent Advantage tanks into the pipe network. */ public class FluidDrainTileEntity extends TileEntitySimpleFluidMachine { public FluidDrainTileEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.FLUID_DRAIN, pos, state, FluidTank.BUCKET_VOLUME, "tile.basepower.fluid_drain.name"); + super(zone.moddev.mc.basepower.init.Entities.FLUID_DRAIN, pos, state, FluidTank.BUCKET_VOLUME, "tile.basepower.fluid_drain.name"); } @Override diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidPipeBlock.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidPipeBlock.java index ecba2974..58cf46f8 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidPipeBlock.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/FluidPipeBlock.java @@ -18,16 +18,21 @@ public class FluidPipeBlock extends BlockSimpleFluidConduit { public FluidPipeBlock() { - super(Properties.of().mapColor(MapColor.STONE).pushReaction(PushReaction.BLOCK) - .forceSolidOn().strength(0.75F), 4F / 16F); + this("fluid_pipe"); } + protected FluidPipeBlock(String id) { + super(Blocks.properties(id, Properties.of().mapColor(MapColor.STONE).pushReaction(PushReaction.BLOCK) + .forceSolidOn().strength(0.75F)), 4F / 16F); + } + /** * Called when a neighboring block changes. */ @Override - public void neighborChanged(BlockState state, Level worldIn, BlockPos pos, Block blockIn, BlockPos fromPos, boolean isMoving) { + public void neighborChanged(BlockState state, Level worldIn, BlockPos pos, Block blockIn, + net.minecraft.world.level.redstone.Orientation orientation, boolean isMoving) { pipeCheck(worldIn, pos); } @@ -40,7 +45,7 @@ public void setPlacedBy(Level worldIn, BlockPos pos, BlockState state, LivingEnt } private void pipeCheck(Level w, BlockPos pos) { - if (!w.isClientSide) { + if (!w.isClientSide()) { if (numberOfAdjacentFluidHandlers(w, pos) > 0) { w.setBlock(pos, Blocks.fluid_pipe_terminal.defaultBlockState(), 3); } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/MetalTankBlock.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/MetalTankBlock.java index 0b5e3673..f1f114ef 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/MetalTankBlock.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/MetalTankBlock.java @@ -12,7 +12,7 @@ public class MetalTankBlock extends BlockSimpleFluidMachine { public MetalTankBlock() { - super(machineProperties(3F)); + super(zone.moddev.mc.basepower.init.Blocks.properties("fluid_metal_tank", machineProperties(3F))); } @Override diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/MetalTankTileEntity.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/MetalTankTileEntity.java index 5f18f196..c2f05ed0 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/MetalTankTileEntity.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/MetalTankTileEntity.java @@ -8,14 +8,14 @@ import net.minecraft.world.level.material.Fluid; import cyano.poweradvantage.api.fluid.FluidStack; import cyano.poweradvantage.api.fluid.FluidTank; -import zone.moddev.mc.basepower.init.Entities; + import zone.moddev.mc.basepower.util.FluidContainerHelper; @SuppressWarnings("deprecation") public class MetalTankTileEntity extends TileEntitySimpleFluidMachine { public MetalTankTileEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.METAL_TANK, pos, state, FluidTank.BUCKET_VOLUME * 10, "tile.basepower.fluid_metal_tank.name"); + super(zone.moddev.mc.basepower.init.Entities.METAL_TANK, pos, state, FluidTank.BUCKET_VOLUME * 10, "tile.basepower.fluid_metal_tank.name"); } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StillBlock.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StillBlock.java index f45cd7e0..a448e11e 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StillBlock.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StillBlock.java @@ -24,8 +24,9 @@ public class StillBlock extends BlockSimpleFluidMachine { public static final BooleanProperty ACTIVE = BooleanProperty.create("active"); public StillBlock() { - super(Properties.of().mapColor(MapColor.STONE).pushReaction(PushReaction.BLOCK).forceSolidOn() - .strength(3F).lightLevel(state -> state.getValue(ACTIVE) ? 12 : 0)); + super(zone.moddev.mc.basepower.init.Blocks.properties("still", Properties.of().mapColor(MapColor.STONE) + .pushReaction(PushReaction.BLOCK).forceSolidOn().strength(3F) + .lightLevel(state -> state.getValue(ACTIVE) ? 12 : 0))); this.registerDefaultState(defaultBlockState().setValue(ACTIVE, false)); } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StillTileEntity.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StillTileEntity.java index f3e3bee0..6d500894 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StillTileEntity.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StillTileEntity.java @@ -17,7 +17,7 @@ import cyano.poweradvantage.api.fluid.FluidStack; import cyano.poweradvantage.api.fluid.FluidTank; import cyano.poweradvantage.api.fluid.FluidTankInfo; -import zone.moddev.mc.basepower.init.Entities; + @SuppressWarnings("deprecation") public class StillTileEntity extends TileEntitySimpleFluidMachine { @@ -42,7 +42,7 @@ public class StillTileEntity extends TileEntitySimpleFluidMachine { public StillTileEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.STILL, pos, state, FluidTank.BUCKET_VOLUME, "tile.basepower.still.name"); + super(zone.moddev.mc.basepower.init.Entities.STILL, pos, state, FluidTank.BUCKET_VOLUME, "tile.basepower.still.name"); } @Override @@ -265,19 +265,19 @@ protected void saveAdditional(final CompoundTag tagRoot, HolderLookup.Provider r @Override protected void loadAdditional(final CompoundTag tagRoot, HolderLookup.Provider registries) { super.loadAdditional(tagRoot, registries); - if (tagRoot.contains("InputTank", 10)) { - CompoundTag tankTag2 = tagRoot .getCompound("InputTank"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tagRoot, "InputTank", 10)) { + CompoundTag tankTag2 = tagRoot .getCompoundOrEmpty("InputTank"); inputTank.readFromNBT(tankTag2); - if (tankTag2.contains("Empty", 1)) { + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tankTag2, "Empty", 1)) { // empty the tank if NBT says its empty (not default behavior of Tank.readFromNBT(...) ) inputTank.setFluid(null); } } - if (tagRoot.contains("burnTime", 99)) { - this.burnTime = tagRoot.getShort("burnTime"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tagRoot, "burnTime", 99)) { + this.burnTime = tagRoot.getShortOr("burnTime", (short) 0); } - if (tagRoot.contains("totalBurnTime", 99)) { - this.totalBurnTime = tagRoot.getShort("totalBurnTime"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tagRoot, "totalBurnTime", 99)) { + this.totalBurnTime = tagRoot.getShortOr("totalBurnTime", (short) 0); } } @@ -412,30 +412,30 @@ public CompoundTag createDataFieldUpdateTag() { @Override public void readDataFieldUpdateTag(CompoundTag tag) { boolean usedFluidNbt = false; - if (tag .contains("InputTank", 10)) { - CompoundTag inputTankTag = tag .getCompound("InputTank"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "InputTank", 10)) { + CompoundTag inputTankTag = tag .getCompoundOrEmpty("InputTank"); inputTank.readFromNBT(inputTankTag); - if (inputTankTag.contains("Empty", 1)) { + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(inputTankTag, "Empty", 1)) { inputTank.setFluid(null); } usedFluidNbt = true; } - if (tag .contains("Tank", 10)) { - CompoundTag outputTankTag = tag .getCompound("Tank"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "Tank", 10)) { + CompoundTag outputTankTag = tag .getCompoundOrEmpty("Tank"); getTank().readFromNBT(outputTankTag); - if (outputTankTag.contains("Empty", 1)) { + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(outputTankTag, "Empty", 1)) { getTank().setFluid(null); } usedFluidNbt = true; } - if (tag.contains("burnTime", 99)) { - this.burnTime = tag.getShort("burnTime"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "burnTime", 99)) { + this.burnTime = tag.getShortOr("burnTime", (short) 0); } - if (tag.contains("totalBurnTime", 99)) { - this.totalBurnTime = tag.getShort("totalBurnTime"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "totalBurnTime", 99)) { + this.totalBurnTime = tag.getShortOr("totalBurnTime", (short) 0); } - if (tag .contains("[]", 11)) { - int[] newData = tag.getIntArray("[]"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "[]", 11)) { + int[] newData = tag.getIntArray("[]").orElseGet(() -> new int[0]); System.arraycopy(newData, 0, this.getDataFieldArray(), 0, Math.min(newData.length, this.getDataFieldArray().length)); if (!usedFluidNbt) { this.onDataFieldUpdate(); diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StorageTankBlock.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StorageTankBlock.java index e1bd397d..51e01cd3 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StorageTankBlock.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StorageTankBlock.java @@ -28,7 +28,7 @@ /** Portable tank block. FluidID and Volume remain unchanged for old item stacks. */ public class StorageTankBlock extends BlockSimpleFluidMachine { - public StorageTankBlock() { super(machineProperties(3F)); } + public StorageTankBlock() { super(zone.moddev.mc.basepower.init.Blocks.properties("fluid_storage_tank", machineProperties(3F))); } @Override public PoweredEntity newBlockEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { return new StorageTankTileEntity(pos, state); } @Override public boolean hasAnalogOutputSignal(BlockState state) { return true; } @@ -45,11 +45,11 @@ public void setPlacedBy(Level world, BlockPos pos, BlockState state, super.setPlacedBy(world, pos, state, placer, stack); CompoundTag tag = ItemData.customData(stack); BlockEntity tile = world.getBlockEntity(pos); - if (tag.contains("FluidID", 8) && tag.contains("Volume", 99) + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "FluidID", 8) && zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "Volume", 99) && tile instanceof StorageTankTileEntity) { - net.minecraft.world.level.material.Fluid fluid = Fluids.byName(tag.getString("FluidID")); + net.minecraft.world.level.material.Fluid fluid = Fluids.byName(tag.getStringOr("FluidID", "")); if (fluid != null) ((StorageTankTileEntity) tile).getTank() - .setFluid(new FluidStack(fluid, tag.getInt("Volume"))); + .setFluid(new FluidStack(fluid, tag.getIntOr("Volume", 0))); } } @@ -57,7 +57,7 @@ public ItemStack createItem(StorageTankTileEntity tile) { ItemStack stack = new ItemStack(this); if (tile.getTank().getFluidAmount() > 0) { CompoundTag tag = new CompoundTag(); - net.minecraft.resources.ResourceLocation id = net.minecraftforge.registries.ForgeRegistries.FLUIDS + net.minecraft.resources.Identifier id = net.minecraftforge.registries.ForgeRegistries.FLUIDS .getKey(tile.getTank().getFluid().getFluid()); if (id != null) tag.putString("FluidID", id.toString()); tag.putInt("Volume", tile.getTank().getFluidAmount()); @@ -66,16 +66,16 @@ public ItemStack createItem(StorageTankTileEntity tile) { return stack; } - @Override public void appendHoverText(ItemStack stack, Item.TooltipContext context, - List tooltip, TooltipFlag flag) { + net.minecraft.world.item.component.TooltipDisplay display, + java.util.function.Consumer tooltip, TooltipFlag flag) { CompoundTag tag = ItemData.customData(stack); - if (!tag.contains("FluidID", 8) || !tag.contains("Volume", 99)) { - tooltip.add(Component.translatable("tooltip.basepower.empty")); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "FluidID", 8) || !zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "Volume", 99)) { + tooltip.accept(Component.translatable("tooltip.basepower.empty")); return; } - float buckets = tag.getInt("Volume") / (float) FluidTank.BUCKET_VOLUME; - tooltip.add(Component.literal(buckets + " buckets of " + tag.getString("FluidID"))); + float buckets = tag.getIntOr("Volume", 0) / (float) FluidTank.BUCKET_VOLUME; + tooltip.accept(Component.literal(buckets + " buckets of " + tag.getStringOr("FluidID", ""))); } /** Retained for callers from the 1.12 implementation. */ diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StorageTankTileEntity.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StorageTankTileEntity.java index a2294831..6f5f7c15 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StorageTankTileEntity.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/StorageTankTileEntity.java @@ -8,13 +8,13 @@ import net.minecraft.world.level.material.Fluid; import cyano.poweradvantage.api.fluid.FluidStack; import cyano.poweradvantage.api.fluid.FluidTank; -import zone.moddev.mc.basepower.init.Entities; + @SuppressWarnings("deprecation") public class StorageTankTileEntity extends TileEntitySimpleFluidMachine { public StorageTankTileEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { - super(Entities.STORAGE_TANK, pos, state, FluidTank.BUCKET_VOLUME * 4, "tile.basepower.fluid_storage_tank.name"); + super(zone.moddev.mc.basepower.init.Entities.STORAGE_TANK, pos, state, FluidTank.BUCKET_VOLUME * 4, "tile.basepower.fluid_storage_tank.name"); } /** diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/modsupport/TerminalFluidPipeBlock.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/modsupport/TerminalFluidPipeBlock.java index 0f8e99b9..c841d524 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/modsupport/TerminalFluidPipeBlock.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/modsupport/TerminalFluidPipeBlock.java @@ -18,6 +18,8 @@ @SuppressWarnings("deprecation") public class TerminalFluidPipeBlock extends FluidPipeBlock implements EntityBlock { + public TerminalFluidPipeBlock() { super("fluid_pipe_terminal"); } + @Override public BlockEntity newBlockEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { return new TerminalFluidPipeTileEntity(pos, state); @@ -38,7 +40,8 @@ public boolean isPowerSource(ConduitType powerType) { * Called when a neighboring block changes. */ @Override - public void neighborChanged(BlockState state, Level worldIn, BlockPos pos, Block blockIn, BlockPos fromPos, boolean isMoving) { + public void neighborChanged(BlockState state, Level worldIn, BlockPos pos, Block blockIn, + net.minecraft.world.level.redstone.Orientation orientation, boolean isMoving) { pipeCheck(worldIn, pos); } @@ -51,7 +54,7 @@ public void setPlacedBy(Level worldIn, BlockPos pos, BlockState state, LivingEnt } private void pipeCheck(Level w, BlockPos pos) { - if (!w.isClientSide) { + if (!w.isClientSide()) { if (numberOfAdjacentFluidHandlers(w, pos) == 0) { w.setBlock(pos, Blocks.fluid_pipe.defaultBlockState(), 3); } diff --git a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/modsupport/TerminalFluidPipeTileEntity.java b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/modsupport/TerminalFluidPipeTileEntity.java index ee16337b..68dab903 100644 --- a/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/modsupport/TerminalFluidPipeTileEntity.java +++ b/src/main/java/zone/moddev/mc/basepower/machines/fluidmachines/modsupport/TerminalFluidPipeTileEntity.java @@ -13,7 +13,7 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.core.Direction; import zone.moddev.mc.basepower.conduitnetwork.ConduitRegistry; -import zone.moddev.mc.basepower.init.Entities; + import zone.moddev.mc.basepower.init.Fluids; import zone.moddev.mc.basepower.util.FluidHandlerHelper; @@ -22,7 +22,7 @@ public class TerminalFluidPipeTileEntity extends PoweredEntity { private final Direction[] faces = Direction.values(); private final IAdvantageFluidHandler[] neighbors = new IAdvantageFluidHandler[faces.length]; - public TerminalFluidPipeTileEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(Entities.PIPE_TERMINAL, pos, state); } + public TerminalFluidPipeTileEntity(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state) { super(zone.moddev.mc.basepower.init.Entities.PIPE_TERMINAL, pos, state); } @Override public void tickUpdate(boolean server) { if (!server) return; diff --git a/src/main/java/zone/moddev/mc/basepower/math/BlockPos4D.java b/src/main/java/zone/moddev/mc/basepower/math/BlockPos4D.java index 7fd6da95..92884a3f 100644 --- a/src/main/java/zone/moddev/mc/basepower/math/BlockPos4D.java +++ b/src/main/java/zone/moddev/mc/basepower/math/BlockPos4D.java @@ -5,7 +5,7 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.core.Direction; import net.minecraft.resources.ResourceKey; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; import net.minecraft.core.BlockPos; import net.minecraft.world.level.Level; @@ -278,7 +278,7 @@ public BlockPos4D[] getNeighbors() { public Tag toNBT() { CompoundTag data = new CompoundTag(); - data.putString("Dimension", dimension.location().toString()); + data.putString("Dimension", dimension.identifier().toString()); data.putIntArray("Pos", new int[] {pos.getX(), pos.getY(), pos.getZ()}); return data; } @@ -290,10 +290,10 @@ public static BlockPos4D fromNBT(Tag tag) { return new BlockPos4D(legacyDimension(data[0]), data[1], data[2], data[3]); } CompoundTag data = (CompoundTag) tag; - int[] pos = data.getIntArray("Pos"); + int[] pos = data.getIntArray("Pos").orElseGet(() -> new int[0]); if (pos.length != 3) throw new IllegalArgumentException("Invalid position"); return new BlockPos4D(ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, - ResourceLocation.parse(data.getString("Dimension"))), pos[0], pos[1], pos[2]); + Identifier.parse(data.getStringOr("Dimension", ""))), pos[0], pos[1], pos[2]); } /** Only the three vanilla numeric IDs have an unambiguous legacy meaning. */ diff --git a/src/main/java/zone/moddev/mc/basepower/migration/AdvantageLegacyMigrations.java b/src/main/java/zone/moddev/mc/basepower/migration/AdvantageLegacyMigrations.java index 53aaf15a..1ea118e8 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/AdvantageLegacyMigrations.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/AdvantageLegacyMigrations.java @@ -5,7 +5,7 @@ import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.block.state.BlockState; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import zone.moddev.mc.basepower.PowerAdvantage; import zone.moddev.mc.basepower.blocks.BlockPowerSwitch; import zone.moddev.mc.basepower.machines.conveyors.BlockConveyor; @@ -87,7 +87,7 @@ private static BlockState decodeState(Block block, int metadata) { return block.defaultBlockState(); } - private static ResourceLocation id(String path) { - return ResourceLocation.fromNamespaceAndPath(PowerAdvantage.MODID, path); + private static Identifier id(String path) { + return Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, path); } } 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 933fa8d2..95715db1 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/BaseMetalsLegacyMigration.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/BaseMetalsLegacyMigration.java @@ -2,7 +2,7 @@ import cyano.poweradvantage.api.migration.LegacyMigrationApi; import net.minecraft.nbt.CompoundTag; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.world.level.block.state.BlockState; import net.minecraftforge.registries.ForgeRegistries; import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension; @@ -12,7 +12,7 @@ public final class BaseMetalsLegacyMigration implements Legacy112MigrationExtens private BaseMetalsLegacyMigration() {} public static void register() { - Legacy112MigrationExtension.register(ResourceLocation.fromNamespaceAndPath("basepower", "legacy_world"), + Legacy112MigrationExtension.register(Identifier.fromNamespaceAndPath("basepower", "legacy_world"), new BaseMetalsLegacyMigration()); } @@ -33,9 +33,9 @@ public void afterPlayerFlattening(CompoundTag preparedSource, CompoundTag result } @Override - public BlockState decodeBlockState(ResourceLocation legacyId, int metadata) { + public BlockState decodeBlockState(Identifier legacyId, int metadata) { if (!LegacyMigrationApi.handlesNamespace(legacyId.getNamespace())) return null; - ResourceLocation target = LegacyMigrationApi.blockTarget(legacyId); + Identifier target = LegacyMigrationApi.blockTarget(legacyId); if (!ForgeRegistries.BLOCKS.containsKey(target)) { throw new IllegalStateException("No replacement for saved Advantage block " + legacyId + " (expected " + target + ")"); } diff --git a/src/main/java/zone/moddev/mc/basepower/migration/LegacyEntityDataFixes.java b/src/main/java/zone/moddev/mc/basepower/migration/LegacyEntityDataFixes.java index 8e3ddfc0..763f000d 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/LegacyEntityDataFixes.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/LegacyEntityDataFixes.java @@ -5,6 +5,7 @@ import com.mojang.serialization.Dynamic; import java.util.Set; import java.util.UUID; +import net.minecraft.core.UUIDUtil; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; import net.minecraft.nbt.NbtOps; @@ -34,22 +35,22 @@ private LegacyEntityDataFixes() {} static void prepare(CompoundTag root) { if (root.contains(STAGED)) return; - int version = root.contains("DataVersion", 99) ? root.getInt("DataVersion") : -1; - int current = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + int version = zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, "DataVersion", 99) ? root.getIntOr("DataVersion", 0) : -1; + int current = SharedConstants.getCurrentVersion().dataVersion().version(); if (version >= current) return; CompoundTag level = entityOwner(root); if (level == null) return; String key = entityKey(root, level); if (!level.contains(key)) return; requireCompoundList(level, key); - ListTag entities = level.getList(key, 10); + ListTag entities = level.getListOrEmpty(key); if (entities.isEmpty()) return; DataFixer fixer = DataFixers.getDataFixer(); Set vanillaIds = fixer.getSchema(DataFixUtils.makeKey(version)) .findChoiceType(References.ENTITY).types().keySet(); ListTag fixed = new ListTag(); for (int index = 0; index < entities.size(); index++) { - fixed.add(upgrade(entities.getCompound(index), version, current, fixer, vanillaIds)); + fixed.add(upgrade(entities.getCompoundOrEmpty(index), version, current, fixer, vanillaIds)); } // Keep these out of vanilla's mixed-choice traversal and avoid fixing them twice. root.put(STAGED, fixed); @@ -61,10 +62,10 @@ private static CompoundTag upgrade(CompoundTag source, int version, int current, CompoundTag entity = source.copy(); renameForgeAttributes(entity); boolean hasPassengers = source.contains("Passengers"); - ListTag passengers = source.getList("Passengers", 10); + ListTag passengers = source.getListOrEmpty("Passengers"); entity.remove("Passengers"); Dynamic dynamic = new Dynamic<>(NbtOps.INSTANCE, entity); - if (vanillaIds.contains(entity.getString("id"))) { + if (vanillaIds.contains(entity.getStringOr("id", ""))) { entity = (CompoundTag) fixer.update(References.ENTITY, dynamic, version, current).getValue(); } else { // All entities inherit this root UUID format, including unknown mod types. @@ -72,12 +73,13 @@ private static CompoundTag upgrade(CompoundTag source, int version, int current, upgradeAttributePayload(entity, version, current, fixer); } // Vanilla's UUID fixer rejects zero halves, although they are valid UUID bits. - if (source.hasUUID("UUID")) { - entity.putUUID("UUID", source.getUUID("UUID")); - } else if (source.contains("UUIDMost", 99) && source.contains("UUIDLeast", 99)) { - entity.putUUID("UUID", new UUID(source.getLong("UUIDMost"), source.getLong("UUIDLeast"))); + var savedUuid = source.getIntArray("UUID").filter(value -> value.length == 4); + if (savedUuid.isPresent()) { + entity.store("UUID", UUIDUtil.CODEC, UUIDUtil.uuidFromIntArray(savedUuid.get())); + } else if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(source, "UUIDMost", 99) && zone.moddev.mc.basepower.util.NbtCompat.containsTag(source, "UUIDLeast", 99)) { + entity.store("UUID", UUIDUtil.CODEC, new UUID(source.getLongOr("UUIDMost", 0L), source.getLongOr("UUIDLeast", 0L))); } - if (entity.hasUUID("UUID")) { + if (entity.getIntArray("UUID").filter(value -> value.length == 4).isPresent()) { entity.remove("UUIDMost"); entity.remove("UUIDLeast"); } @@ -85,7 +87,7 @@ private static CompoundTag upgrade(CompoundTag source, int version, int current, requireCompoundList(source, "Passengers"); ListTag fixed = new ListTag(); for (int index = 0; index < passengers.size(); index++) { - fixed.add(upgrade(passengers.getCompound(index), version, current, fixer, vanillaIds)); + fixed.add(upgrade(passengers.getCompoundOrEmpty(index), version, current, fixer, vanillaIds)); } entity.put("Passengers", fixed); } @@ -100,7 +102,7 @@ private static void upgradeAttributePayload(CompoundTag entity, int version, int // Attribute records have the same native schema on players and living entities. // Isolate this vanilla-owned field rather than submitting an unknown mod entity choice. CompoundTag fixed = DataFixTypes.PLAYER.update(fixer, input, version, current); - if (!fixed.contains("attributes", Tag.TAG_LIST)) { + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(fixed, "attributes", Tag.TAG_LIST)) { throw new IllegalStateException("Native attribute data fix did not produce the current format"); } entity.remove("Attributes"); @@ -110,36 +112,36 @@ private static void upgradeAttributePayload(CompoundTag entity, int version, int static void preparePlayerAttributes(CompoundTag player) { renameForgeAttributes(player); if (!player.contains("RootVehicle")) return; - if (!player.contains("RootVehicle", 10)) throw new IllegalStateException("Malformed player RootVehicle"); - CompoundTag vehicle = player.getCompound("RootVehicle"); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(player, "RootVehicle", 10)) throw new IllegalStateException("Malformed player RootVehicle"); + CompoundTag vehicle = player.getCompoundOrEmpty("RootVehicle"); if (!vehicle.contains("Entity")) return; - if (!vehicle.contains("Entity", 10)) throw new IllegalStateException("Malformed player RootVehicle.Entity"); - prepareAttributeTree(vehicle.getCompound("Entity")); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(vehicle, "Entity", 10)) throw new IllegalStateException("Malformed player RootVehicle.Entity"); + prepareAttributeTree(vehicle.getCompoundOrEmpty("Entity")); } private static void prepareAttributeTree(CompoundTag entity) { renameForgeAttributes(entity); if (!entity.contains("Passengers")) return; requireCompoundList(entity, "Passengers"); - ListTag passengers = entity.getList("Passengers", 10); + ListTag passengers = entity.getListOrEmpty("Passengers"); for (int index = 0; index < passengers.size(); index++) { - prepareAttributeTree(passengers.getCompound(index)); + prepareAttributeTree(passengers.getCompoundOrEmpty(index)); } } private static void renameForgeAttributes(CompoundTag entity) { if (!entity.contains("Attributes")) return; requireCompoundList(entity, "Attributes"); - ListTag attributes = entity.getList("Attributes", 10); + ListTag attributes = entity.getListOrEmpty("Attributes"); for (String[] alias : FORGE_ATTRIBUTES) { CompoundTag legacy = null; int identities = 0; for (int index = 0; index < attributes.size(); index++) { - CompoundTag attribute = attributes.getCompound(index); - if (attribute.getString("Name").equals(alias[0])) { + CompoundTag attribute = attributes.getCompoundOrEmpty(index); + if (attribute.getStringOr("Name", "").equals(alias[0])) { legacy = attribute; identities++; - } else if (attribute.getString("Name").equals(alias[1])) { + } else if (attribute.getStringOr("Name", "").equals(alias[1])) { identities++; } } @@ -150,26 +152,26 @@ private static void renameForgeAttributes(CompoundTag entity) { static void finish(CompoundTag root) { if (root == null || !root.contains(STAGED)) return; - if (!root.contains(STAGED, 9) || entityOwner(root) == null) { + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, STAGED, 9) || entityOwner(root) == null) { throw new IllegalStateException("Malformed staged Advantage entity migration"); } requireCompoundList(root, STAGED); CompoundTag level = entityOwner(root); String key = entityKey(root, level); if (level.contains(key)) requireCompoundList(level, key); - if (!level.getList(key, 10).isEmpty()) { + if (!level.getListOrEmpty(key).isEmpty()) { throw new IllegalStateException("Entity data appeared during isolated Advantage migration"); } - level.put(key, root.getList(STAGED, 10)); + level.put(key, root.getListOrEmpty(STAGED)); root.remove(STAGED); } private static CompoundTag entityOwner(CompoundTag root) { - if (root.contains("Level", 10)) return root.getCompound("Level"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, "Level", 10)) return root.getCompoundOrEmpty("Level"); if (!root.contains("Position")) { - return root.contains("xPos", 99) && root.contains("zPos", 99) ? root : null; + return zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, "xPos", 99) && zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, "zPos", 99) ? root : null; } - if (root.getIntArray("Position").length != 2) { + if (root.getIntArray("Position").orElseGet(() -> new int[0]).length != 2) { throw new IllegalStateException("Saved entity chunk has no containing chunk coordinates"); } return root; @@ -181,9 +183,9 @@ private static String entityKey(CompoundTag root, CompoundTag owner) { } private static void requireCompoundList(CompoundTag owner, String key) { - if (!owner.contains(key, 9)) throw new IllegalStateException("Malformed entity list: " + key); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(owner, key, 9)) throw new IllegalStateException("Malformed entity list: " + key); ListTag list = (ListTag) owner.get(key); - if (!list.isEmpty() && list.getElementType() != 10) { + if (list.stream().anyMatch(value -> !(value instanceof CompoundTag))) { throw new IllegalStateException("Non-compound entity list: " + key); } } diff --git a/src/main/java/zone/moddev/mc/basepower/migration/LegacyMachineItemDataFixes.java b/src/main/java/zone/moddev/mc/basepower/migration/LegacyMachineItemDataFixes.java index 7a992db9..8e5ac6d5 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/LegacyMachineItemDataFixes.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/LegacyMachineItemDataFixes.java @@ -10,7 +10,7 @@ import net.minecraft.nbt.Tag; import net.minecraft.nbt.ListTag; import net.minecraft.nbt.NbtOps; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.SharedConstants; import net.minecraft.util.datafix.DataFixers; import net.minecraft.util.datafix.fixes.References; @@ -23,13 +23,13 @@ final class LegacyMachineItemDataFixes { private LegacyMachineItemDataFixes() {} static void prepare(CompoundTag root) { - int version = root.contains("DataVersion", 99) ? root.getInt("DataVersion") : -1; - int current = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); - if (version >= current || root.getBoolean(PREPARED) || !root.contains("Level", 10)) return; - ListTag tiles = root.getCompound("Level").getList("TileEntities", 10); + int version = zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, "DataVersion", 99) ? root.getIntOr("DataVersion", 0) : -1; + int current = SharedConstants.getCurrentVersion().dataVersion().version(); + if (version >= current || root.getBooleanOr(PREPARED, false) || !zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, "Level", 10)) return; + ListTag tiles = compoundList(root.getCompoundOrEmpty("Level"), "TileEntities"); for (int index = 0; index < tiles.size(); index++) { - CompoundTag tile = tiles.getCompound(index); - ResourceLocation id = ResourceLocation.tryParse(tile.getString("id")); + CompoundTag tile = tiles.getCompoundOrEmpty(index); + Identifier id = Identifier.tryParse(tile.getStringOr("id", "")); if (id != null && LegacyMigrationApi.handlesNamespace(id.getNamespace())) { rewrite(tile, version, current); } @@ -40,8 +40,8 @@ static void prepare(CompoundTag root) { private static Tag rewrite(Tag value, int version, int current) { if (value instanceof CompoundTag) { CompoundTag compound = (CompoundTag) value; - ResourceLocation id = ResourceLocation.tryParse(compound.getString("id")); - if (compound.contains("Count", 99) && id != null) { + Identifier id = Identifier.tryParse(compound.getStringOr("id", "")); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(compound, "Count", 99) && id != null) { if (supported(id)) { DataFixer fixer = DataFixers.getDataFixer(); CompoundTag input = version < 704 ? upgradeTo704(compound, version, fixer) : compound.copy(); @@ -59,7 +59,7 @@ private static Tag rewrite(Tag value, int version, int current) { } return compound; } - for (String key : new ArrayList<>(compound.getAllKeys())) { + for (String key : new ArrayList<>(compound.keySet())) { compound.put(key, rewrite(compound.get(key), version, current)); } } else if (value instanceof ListTag) { @@ -82,12 +82,12 @@ static CompoundTag upgradeTo704(CompoundTag source, int version, DataFixer fixer throw new IllegalStateException("Native item envelope is not a player compound"); } Tag items = ((CompoundTag) converted).get("Inventory"); - if (!(items instanceof ListTag) || ((ListTag) items).size() != 1 || ((ListTag) items).getElementType() != 10) { + if (!(items instanceof ListTag) || ((ListTag) items).size() != 1 || !(((ListTag) items).get(0) instanceof CompoundTag)) { throw new IllegalStateException("Native item envelope changed singleton inventory cardinality or type"); } - CompoundTag result = ((ListTag) items).getCompound(0); - ResourceLocation id = ResourceLocation.tryParse(result.getString("id")); - if (!result.contains("id", 8) || id == null || id.getPath().isEmpty() || !result.contains("Count", 99) + CompoundTag result = ((ListTag) items).getCompoundOrEmpty(0); + Identifier id = Identifier.tryParse(result.getStringOr("id", "")); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(result, "id", 8) || id == null || id.getPath().isEmpty() || !zone.moddev.mc.basepower.util.NbtCompat.containsTag(result, "Count", 99) || !result.get("Count").equals(source.get("Count")) || !Objects.equals(result.get("Slot"), source.get("Slot"))) { throw new IllegalStateException("Native item envelope changed item shape, count or slot"); @@ -95,7 +95,7 @@ static CompoundTag upgradeTo704(CompoundTag source, int version, DataFixer fixer return result; } - private static boolean supported(ResourceLocation id) { + private static boolean supported(Identifier id) { return id.getNamespace().equals("minecraft") || id.getNamespace().equals("basemetals") || id.getNamespace().equals("mmdlib") || LegacyMigrationApi.handlesNamespace(id.getNamespace()); } @@ -103,24 +103,25 @@ private static boolean supported(ResourceLocation id) { private static Tag preserve(Tag original, Tag result) { if (original instanceof CompoundTag && result instanceof CompoundTag) { CompoundTag before = (CompoundTag) original, after = (CompoundTag) result; - ResourceLocation id = ResourceLocation.tryParse(before.getString("id")); - if (before.contains("Count", 99) && id != null) { - if (!after.contains("Count", 99) || before.getInt("Count") != after.getInt("Count") - || (before.contains("Slot", 99) && (!after.contains("Slot", 99) - || before.getInt("Slot") != after.getInt("Slot")))) { + Identifier id = Identifier.tryParse(before.getStringOr("id", "")); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(before, "Count", 99) && id != null) { + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(after, "Count", 99) || before.getIntOr("Count", 0) != after.getIntOr("Count", 0) + || (zone.moddev.mc.basepower.util.NbtCompat.containsTag(before, "Slot", 99) && (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(after, "Slot", 99) + || before.getIntOr("Slot", 0) != after.getIntOr("Slot", 0)))) { throw new IllegalStateException("Native item migration changed inventory count or slot"); } - if (!supported(id)) return ItemData.withLegacyWear(before.copy(), BuiltInRegistries.ITEM.asLookup()); + if (!supported(id)) return ItemData.withLegacyWear(before.copy(), BuiltInRegistries.ITEM); restoreNamedEnchantments(before, after, "ench", "Enchantments"); restoreNamedEnchantments(before, after, "StoredEnchantments", "StoredEnchantments"); } - for (String key : new ArrayList<>(before.getAllKeys())) { + for (String key : new ArrayList<>(before.keySet())) { if (after.contains(key)) after.put(key, preserve(before.get(key), after.get(key))); } } else if (original instanceof ListTag && result instanceof ListTag) { ListTag before = (ListTag) original, after = (ListTag) result; boolean inventory = before.stream().anyMatch(value -> value instanceof CompoundTag - && ((CompoundTag) value).contains("Count", 99) && ((CompoundTag) value).contains("id", 8)); + && zone.moddev.mc.basepower.util.NbtCompat.containsTag((CompoundTag) value, "Count", 99) + && zone.moddev.mc.basepower.util.NbtCompat.containsTag((CompoundTag) value, "id", 8)); if (inventory && before.size() != after.size()) { throw new IllegalStateException("Native item migration changed inventory cardinality"); } @@ -132,20 +133,25 @@ private static Tag preserve(Tag original, Tag result) { } private static void restoreNamedEnchantments(CompoundTag source, CompoundTag target, String oldKey, String newKey) { - ListTag before = source.getCompound("tag").getList(oldKey, 10); - ListTag after = target.getCompound("tag").getList(newKey, 10); + ListTag before = compoundList(source.getCompoundOrEmpty("tag"), oldKey); + ListTag after = compoundList(target.getCompoundOrEmpty("tag"), newKey); for (int index = 0; index < before.size(); index++) { - if (!before.getCompound(index).contains("id", 8)) continue; + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(before.getCompoundOrEmpty(index), "id", 8)) continue; if (after.size() != before.size()) { throw new IllegalStateException("Native item migration changed a named enchantment list's size"); } // The saved Forge registry has already translated these names. Vanilla's // numeric enchantment fixer must not reinterpret them as protection. - after.set(index, before.getCompound(index).copy()); + after.set(index, before.getCompoundOrEmpty(index).copy()); } } static void finish(CompoundTag root) { if (root != null) root.remove(PREPARED); } + + private static ListTag compoundList(CompoundTag owner, String key) { + return owner.getList(key).filter(values -> values.stream().allMatch(CompoundTag.class::isInstance)) + .orElseGet(ListTag::new); + } } diff --git a/src/main/java/zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes.java b/src/main/java/zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes.java index 5fd34420..b7cf4415 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes.java @@ -30,7 +30,7 @@ public static Dynamic updateNativeEmbeddedPlayer(DataFixTypes type, DataF public static CompoundTag update(DataFixer fixer, DataFixTypes type, CompoundTag source, int version) { if (type != DataFixTypes.PLAYER) throw new IllegalArgumentException("Expected native PLAYER type"); CompoundTag result = source.copy(); - int current = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + int current = SharedConstants.getCurrentVersion().dataVersion().version(); if (version >= current) return result; LegacyEntityDataFixes.preparePlayerAttributes(result); if (version < 704) { @@ -58,7 +58,7 @@ static void preserveNames(Tag original, Tag result) { restoreNamed(before, after, "ench", "Enchantments"); restoreNamed(before, after, "StoredEnchantments", "StoredEnchantments"); } - for (String key : before.getAllKeys()) { + for (String key : before.keySet()) { if (after.contains(key)) preserveNames(before.get(key), after.get(key)); else if (containsStack(before.get(key))) { throw new IllegalStateException("Native player migration removed an inventory branch"); @@ -80,14 +80,14 @@ else if (containsStack(before.get(key))) { private static boolean isStack(Tag value) { return value instanceof CompoundTag && ((CompoundTag) value).contains("id") - && ((CompoundTag) value).contains("Count", 99); + && zone.moddev.mc.basepower.util.NbtCompat.containsTag((CompoundTag) value, "Count", 99); } private static boolean containsStack(Tag value) { if (isStack(value)) return true; if (value instanceof CompoundTag) { CompoundTag compound = (CompoundTag) value; - for (String key : compound.getAllKeys()) if (containsStack(compound.get(key))) return true; + for (String key : compound.keySet()) if (containsStack(compound.get(key))) return true; } else if (value instanceof ListTag) { for (Tag child : (ListTag) value) if (containsStack(child)) return true; } @@ -95,15 +95,20 @@ private static boolean containsStack(Tag value) { } private static void restoreNamed(CompoundTag source, CompoundTag target, String oldKey, String newKey) { - ListTag before = source.getCompound("tag").getList(oldKey, 10); - ListTag after = target.getCompound("tag").getList(newKey, 10); + ListTag before = compoundList(source.getCompoundOrEmpty("tag"), oldKey); + ListTag after = compoundList(target.getCompoundOrEmpty("tag"), newKey); for (int index = 0; index < before.size(); index++) { - if (!before.getCompound(index).contains("id", 8)) continue; + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(before.getCompoundOrEmpty(index), "id", 8)) continue; if (before.size() != after.size()) { throw new IllegalStateException("Native player migration changed named enchantment cardinality"); } // Restore only the saved name, never the item's foreign payload or native changes. - after.getCompound(index).putString("id", before.getCompound(index).getString("id")); + after.getCompoundOrEmpty(index).putString("id", before.getCompoundOrEmpty(index).getStringOr("id", "")); } } + + private static ListTag compoundList(CompoundTag owner, String key) { + return owner.getList(key).filter(values -> values.stream().allMatch(CompoundTag.class::isInstance)) + .orElseGet(ListTag::new); + } } 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 ea899a1b..62f9e239 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldDataHook.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldDataHook.java @@ -25,6 +25,7 @@ import com.mojang.serialization.Dynamic; import cyano.poweradvantage.api.migration.LegacyMigrationApi; +import net.minecraft.core.registries.Registries; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.nbt.NbtIo; @@ -33,7 +34,7 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; import net.minecraft.nbt.NbtUtils; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.util.datafix.fixes.BlockStateData; import net.minecraftforge.fml.util.ObfuscationReflectionHelper; import net.minecraftforge.registries.ForgeRegistries; @@ -44,12 +45,12 @@ * worlds. Add-ons contribute their identities through {@link LegacyMigrationApi}. */ public final class LegacyWorldDataHook { - private static final ResourceLocation BLOCK_REGISTRY = ResourceLocation.fromNamespaceAndPath("minecraft", "blocks"); + private static final Identifier BLOCK_REGISTRY = Identifier.fromNamespaceAndPath("minecraft", "blocks"); private static final Set PREPARED_WORLDS = ConcurrentHashMap.newKeySet(); private static final String REGISTRY_SIDECAR = "basepower-legacy-registry.dat"; private static final BitSet LEGACY_BLOCK_IDS = new BitSet(); private static final Map LEGACY_ENCHANTMENTS = new LinkedHashMap<>(); - private static final Map> LEGACY_CHUNKS = new ConcurrentHashMap<>(); + private static final Map> LEGACY_CHUNKS = new ConcurrentHashMap<>(); private static final Map LEGACY_BLOCK_COUNTS = new ConcurrentHashMap<>(); private static final Map VANILLA_BLOCK_ENTITY_IDS = vanillaBlockEntityIds(); private static final String PRESERVE_CHUNK_MARKER = "AdvantageLegacyPreserveChunk"; @@ -74,7 +75,7 @@ public static synchronized void prepareLegacyWorld(File levelDat) { throw new IOException("Invalid retained Advantage registry snapshot " + source); } preserveLegacyRegistry(levelDat, registryArchive); - prepareLegacyData(levelDat.getParentFile(), registryArchive.getCompound("FML")); + prepareLegacyData(levelDat.getParentFile(), registryArchive.getCompoundOrEmpty("FML")); } } if (levelDat.isFile()) { @@ -90,10 +91,10 @@ public static synchronized void prepareLegacyWorld(File levelDat) { } private static boolean hasAdvantageNamespaceAliases(CompoundTag root) { - CompoundTag registries = root.getCompound("fml").getCompound("Registries"); - for (String registry : registries.getAllKeys()) { - for (Tag entry : registries.getCompound(registry).getList("ids", Tag.TAG_COMPOUND)) { - ResourceLocation id = safeId(((CompoundTag) entry).getString("K")); + CompoundTag registries = root.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries"); + for (String registry : registries.keySet()) { + for (Tag entry : registries.getCompoundOrEmpty(registry).getListOrEmpty("ids")) { + Identifier id = safeId(((CompoundTag) entry).getStringOr("K", "")); if (id != null && !LegacyMigrationApi.registryTarget(id).equals(id)) return true; } } @@ -109,9 +110,10 @@ static void prepareForgeRegistryMetadata(File levelDat) { } boolean changed = removeRetiredVanillaProfessionRegistry(root); changed |= renameFlattenedBiomeRegistry(root); + changed |= normalizeFlattenedRegistryNames(root); changed |= archiveDataDrivenEnchantmentIndex(root); if (changed) { - preserveRetiredRegistryArchive(levelDat, root.getCompound("basepower:legacy_registry_indexes")); + preserveRetiredRegistryArchive(levelDat, root.getCompoundOrEmpty("basepower:legacy_registry_indexes")); writeWithBackup(levelDat, root); } } catch (IOException exception) { @@ -119,15 +121,48 @@ static void prepareForgeRegistryMetadata(File levelDat) { } } + static boolean normalizeFlattenedRegistryNames(CompoundTag root) { + if (root.getCompoundOrEmpty("Data").getIntOr("DataVersion", 0) < 1519) return false; + // Only flattened lowercase snapshots: uppercase FML supplies original numeric IDs. + CompoundTag registries = root.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries"); + Map aliases = Map.of( + "minecraft:potions", Registries.MOB_EFFECT.identifier().toString(), + "minecraft:potiontypes", Registries.POTION.identifier().toString(), + "minecraft:soundevents", Registries.SOUND_EVENT.identifier().toString(), + "minecraft:enchantments", Registries.ENCHANTMENT.identifier().toString()); + // Validate every pair before changing any key; never merge conflicting indexes. + for (Map.Entry alias : aliases.entrySet()) { + Tag legacy = registries.get(alias.getKey()); + if (legacy == null) continue; + if (!(legacy instanceof CompoundTag)) { + throw new IllegalStateException("Malformed historical registry snapshot " + alias.getKey()); + } + Tag current = registries.get(alias.getValue()); + if (current != null && !legacy.equals(current)) { + throw new IllegalStateException("Conflicting historical registry snapshots " + alias.getKey() + + " and " + alias.getValue() + "; preserve the world backup"); + } + } + boolean changed = false; + for (Map.Entry alias : aliases.entrySet()) { + Tag legacy = registries.get(alias.getKey()); + if (legacy == null) continue; + registries.put(alias.getValue(), legacy.copy()); + registries.remove(alias.getKey()); + changed = true; + } + return changed; + } + static boolean archiveDataDrivenEnchantmentIndex(CompoundTag root) { // Flattened items use named enchantments. Forge 52 no longer has this // static registry; keep its exact index for recovery, not in GameData. // Uppercase pre-flattening FML remains untouched for numeric-ID migration. - CompoundTag registries = root.getCompound("fml").getCompound("Registries"); + CompoundTag registries = root.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries"); String key = "minecraft:enchantment"; - if (!registries.contains(key, Tag.TAG_COMPOUND)) return false; - CompoundTag snapshot = registries.getCompound(key); - CompoundTag archive = root.getCompound("basepower:legacy_registry_indexes"); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(registries, key, Tag.TAG_COMPOUND)) return false; + CompoundTag snapshot = registries.getCompoundOrEmpty(key); + CompoundTag archive = root.getCompoundOrEmpty("basepower:legacy_registry_indexes"); if (archive.contains(key) && !snapshot.equals(archive.get(key))) { throw new IllegalStateException("Conflicting historical enchantment registry indexes; keep the world backup"); } @@ -158,11 +193,11 @@ private static void preserveRetiredRegistryArchive(File levelDat, CompoundTag ar static boolean renameFlattenedBiomeRegistry(CompoundTag root) { // Uppercase FML remains the untouched pre-flattening registry archive. - if (root.getCompound("Data").getInt("DataVersion") < 1519) return false; - CompoundTag registries = root.getCompound("fml").getCompound("Registries"); + if (root.getCompoundOrEmpty("Data").getIntOr("DataVersion", 0) < 1519) return false; + CompoundTag registries = root.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries"); String old = "minecraft:biomes", current = "minecraft:worldgen/biome"; - if (!registries.contains(old, 10)) return false; - CompoundTag snapshot = registries.getCompound(old); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(registries, old, 10)) return false; + CompoundTag snapshot = registries.getCompoundOrEmpty(old); if (registries.contains(current) && !snapshot.equals(registries.get(current))) { throw new IllegalStateException("Conflicting old and current biome registry snapshots; preserve the world backup and resolve the registry conflict before loading"); } @@ -174,21 +209,21 @@ static boolean renameFlattenedBiomeRegistry(CompoundTag root) { static boolean removeRetiredVanillaProfessionRegistry(CompoundTag root) { boolean changed = false; for (String namespace : new String[] {"fml", "FML"}) { - CompoundTag registries = root.getCompound(namespace).getCompound("Registries"); + CompoundTag registries = root.getCompoundOrEmpty(namespace).getCompoundOrEmpty("Registries"); String key = "minecraft:villagerprofessions"; - if (!registries.contains(key, 10)) continue; - CompoundTag snapshot = registries.getCompound(key); - if (!snapshot.contains("ids", 9)) continue; + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(registries, key, 10)) continue; + CompoundTag snapshot = registries.getCompoundOrEmpty(key); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(snapshot, "ids", 9)) continue; boolean vanillaOnly = true; for (Tag entry : (ListTag) snapshot.get("ids")) { if (!(entry instanceof CompoundTag) || !Arrays.asList("minecraft:farmer", "minecraft:librarian", "minecraft:priest", "minecraft:smith", "minecraft:butcher", "minecraft:nitwit") - .contains(((CompoundTag) entry).getString("K"))) vanillaOnly = false; + .contains(((CompoundTag) entry).getStringOr("K", ""))) vanillaOnly = false; } for (String list : new String[] {"aliases", "overrides", "dummied"}) { - if (snapshot.contains(list, 9) && !((ListTag) snapshot.get(list)).isEmpty()) vanillaOnly = false; + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(snapshot, list, 9) && !((ListTag) snapshot.get(list)).isEmpty()) vanillaOnly = false; } - if (snapshot.getIntArray("blocked").length > 0 || !vanillaOnly) continue; + if (snapshot.getIntArray("blocked").orElseGet(() -> new int[0]).length > 0 || !vanillaOnly) continue; // Vanilla fixes the villagers and offers. Remove only the retired vanilla ID index. registries.remove(key); changed = true; @@ -211,13 +246,13 @@ static synchronized boolean prepareExternalPayload(CompoundTag payload, boolean } static synchronized void rememberLegacyChunk(CompoundTag root) { - if (!root.contains("Level", Tag.TAG_COMPOUND)) return; - CompoundTag level = root.getCompound("Level"); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, "Level", Tag.TAG_COMPOUND)) return; + CompoundTag level = root.getCompoundOrEmpty("Level"); remapFlattenedPalette(level); if (!legacyWorldActive) return; int legacyBlocks = countLegacyBlocks(level); if (legacyBlocks > 0) { - LEGACY_BLOCK_COUNTS.put(Long.valueOf(chunkKey(level.getInt("xPos"), level.getInt("zPos"))), + LEGACY_BLOCK_COUNTS.put(Long.valueOf(chunkKey(level.getIntOr("xPos", 0), level.getIntOr("zPos", 0))), Integer.valueOf(legacyBlocks)); } level.putBoolean("TerrainPopulated", true); @@ -226,12 +261,12 @@ static synchronized void rememberLegacyChunk(CompoundTag root) { } static void remapFlattenedPalette(CompoundTag level) { - for (Tag section : level.getList("Sections", Tag.TAG_COMPOUND)) { - for (Tag state : ((CompoundTag) section).getList("Palette", Tag.TAG_COMPOUND)) { + for (Tag section : level.getListOrEmpty("Sections")) { + for (Tag state : ((CompoundTag) section).getListOrEmpty("Palette")) { CompoundTag blockState = (CompoundTag) state; - ResourceLocation original = safeId(blockState.getString("Name")); + Identifier original = safeId(blockState.getStringOr("Name", "")); if (original == null) continue; - ResourceLocation target = LegacyMigrationApi.blockTarget(original); + Identifier target = LegacyMigrationApi.blockTarget(original); if (target.equals(original)) continue; if (!ForgeRegistries.BLOCKS.containsKey(target)) { throw new IllegalStateException("No replacement for saved Advantage block " + original); @@ -253,8 +288,8 @@ public static java.util.concurrent.CompletableFuture new int[0]).length != 2 || !zone.moddev.mc.basepower.util.NbtCompat.containsTag(source, "Entities", 9)) { throw new IllegalStateException("Malformed saved entity chunk; refusing unsafe construction"); } CompoundTag root = source.copy(); @@ -281,10 +316,10 @@ public static CompoundTag finalizeLegacyChunk(CompoundTag root) { LegacyEntityDataFixes.finish(root); LegacyMachineItemDataFixes.finish(root); if (root != null) { - CompoundTag level = root.contains("Level", 10) ? root.getCompound("Level") : root; - if (level.getBoolean(PRESERVE_CHUNK_MARKER)) { + CompoundTag level = zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, "Level", 10) ? root.getCompoundOrEmpty("Level") : root; + if (level.getBooleanOr(PRESERVE_CHUNK_MARKER, false)) { // Let vanilla fill newly added underground space without rewriting the old surface. - if (!level.contains("below_zero_retrogen", 10)) level.putString("Status", "full"); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(level, "below_zero_retrogen", 10)) level.putString("Status", "full"); level.remove(PRESERVE_CHUNK_MARKER); } } @@ -294,10 +329,10 @@ public static CompoundTag finalizeLegacyChunk(CompoundTag root) { public static boolean shouldBlockWorldgenWrite(net.minecraft.server.level.WorldGenRegion region, net.minecraft.core.BlockPos position) { return legacyWorldActive && position != null - && isProtectedChunk(region.getLevel().dimension().location(), position); + && isProtectedChunk(region.getLevel().dimension().identifier(), position); } - static boolean isProtectedChunk(ResourceLocation dimension, net.minecraft.core.BlockPos position) { + static boolean isProtectedChunk(Identifier dimension, net.minecraft.core.BlockPos position) { Set chunks = LEGACY_CHUNKS.get(dimension); // Do not block the native 1.18 underground extension or newly added height. return position.getY() >= 0 && position.getY() < 256 && chunks != null @@ -305,13 +340,13 @@ static boolean isProtectedChunk(ResourceLocation dimension, net.minecraft.core.B } private static synchronized void prepareLegacyData(File worldDirectory, CompoundTag tag) { - if (!tag.contains("Registries", 10)) return; - CompoundTag registries = tag.getCompound("Registries"); - if (!registries.contains(BLOCK_REGISTRY.toString(), 10)) return; + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "Registries", 10)) return; + CompoundTag registries = tag.getCompoundOrEmpty("Registries"); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(registries, BLOCK_REGISTRY.toString(), 10)) return; installLegacyEnchantments(registries); String key = worldKey(worldDirectory); boolean first = PREPARED_WORLDS.add(key); - int states = installLegacyBlockStates(registries.getCompound(BLOCK_REGISTRY.toString())); + int states = installLegacyBlockStates(registries.getCompoundOrEmpty(BLOCK_REGISTRY.toString())); legacyWorldActive = states > 0; int chunks = indexLegacyChunks(worldDirectory); if (first && states > 0) { @@ -323,14 +358,14 @@ private static synchronized void prepareLegacyData(File worldDirectory, Compound private static int installLegacyBlockStates(CompoundTag snapshot) { LEGACY_BLOCK_IDS.clear(); - Map ids = new LinkedHashMap<>(); - ListTag savedIds = snapshot.getList("ids", 10); + Map ids = new LinkedHashMap<>(); + ListTag savedIds = snapshot.getListOrEmpty("ids"); int highestState = 0; for (int index = 0; index < savedIds.size(); index++) { - CompoundTag entry = savedIds.getCompound(index); - ResourceLocation id = safeId(normalizeLegacyRegistryName(entry.getString("K"))); + CompoundTag entry = savedIds.getCompoundOrEmpty(index); + Identifier id = safeId(normalizeLegacyRegistryName(entry.getStringOr("K", ""))); if (id == null || !LegacyMigrationApi.handlesNamespace(id.getNamespace())) continue; - int numeric = entry.getInt("V"); + int numeric = entry.getIntOr("V", 0); if (numeric < 0 || numeric > 4095) { throw new IllegalStateException("Invalid legacy block ID for " + id + ": " + numeric); } @@ -342,8 +377,8 @@ private static int installLegacyBlockStates(CompoundTag snapshot) { Dynamic[] table = expandFlatteningTable(highestState + 1); int mapped = 0; - for (Map.Entry entry : ids.entrySet()) { - ResourceLocation target = LegacyMigrationApi.blockTarget(entry.getKey()); + for (Map.Entry entry : ids.entrySet()) { + Identifier target = LegacyMigrationApi.blockTarget(entry.getKey()); Block block = ForgeRegistries.BLOCKS.getValue(target); if (!ForgeRegistries.BLOCKS.containsKey(target) || block == null) { throw new IllegalStateException("No Advantage replacement for legacy block " + entry.getKey() @@ -352,7 +387,7 @@ private static int installLegacyBlockStates(CompoundTag snapshot) { for (int metadata = 0; metadata < 16; metadata++) { BlockState state = LegacyMigrationApi.decodeBlockState(entry.getKey(), block, metadata); table[(entry.getValue().intValue() << 4) | metadata] = - BlockStateData.parse(NbtUtils.writeBlockState(state).toString()); + new Dynamic<>(net.minecraft.nbt.NbtOps.INSTANCE, NbtUtils.writeBlockState(state)); mapped++; } } @@ -361,11 +396,11 @@ private static int installLegacyBlockStates(CompoundTag snapshot) { private static void installLegacyEnchantments(CompoundTag registries) { LEGACY_ENCHANTMENTS.clear(); - ListTag entries = registries.getCompound("minecraft:enchantments").getList("ids", 10); + ListTag entries = registries.getCompoundOrEmpty("minecraft:enchantments").getListOrEmpty("ids"); for (int index = 0; index < entries.size(); index++) { - CompoundTag entry = entries.getCompound(index); - ResourceLocation name = safeId(entry.getString("K")); - if (name != null) LEGACY_ENCHANTMENTS.put(Integer.valueOf(entry.getInt("V")), name.toString()); + CompoundTag entry = entries.getCompoundOrEmpty(index); + Identifier name = safeId(entry.getStringOr("K", "")); + if (name != null) LEGACY_ENCHANTMENTS.put(Integer.valueOf(entry.getIntOr("V", 0)), name.toString()); } } @@ -373,15 +408,15 @@ private static void installLegacyEnchantments(CompoundTag registries) { static boolean migrateLegacyEnchantments(CompoundTag compound, Map savedIds) { boolean changed = migrateEnchantmentList(compound, "StoredEnchantments", savedIds); changed |= migrateEnchantmentList(compound, "Enchantments", savedIds); - if (compound.contains("ench", 9)) { - ListTag enchantments = compound.getList("ench", 10); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(compound, "ench", 9)) { + ListTag enchantments = compound.getListOrEmpty("ench"); if (!enchantments.isEmpty() && !savedIds.isEmpty()) { // A partial conversion would leave an unreadable mixed old/new list. for (int index = 0; index < enchantments.size(); index++) { - CompoundTag enchantment = enchantments.getCompound(index); - if (enchantment.contains("id", 99) - && !savedIds.containsKey(Integer.valueOf(enchantment.getInt("id")))) { - throw new IllegalStateException("Missing saved enchantment mapping for " + enchantment.getInt("id")); + CompoundTag enchantment = enchantments.getCompoundOrEmpty(index); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(enchantment, "id", 99) + && !savedIds.containsKey(Integer.valueOf(enchantment.getIntOr("id", 0)))) { + throw new IllegalStateException("Missing saved enchantment mapping for " + enchantment.getIntOr("id", 0)); } } migrateEnchantmentList(compound, "ench", savedIds); @@ -395,13 +430,13 @@ static boolean migrateLegacyEnchantments(CompoundTag compound, Map savedIds) { boolean changed = false; - ListTag list = compound.getList(key, 10); + ListTag list = compound.getListOrEmpty(key); for (int index = 0; index < list.size(); index++) { - CompoundTag enchantment = list.getCompound(index); - boolean numeric = enchantment.contains("id", 99); - String name = numeric ? savedIds.get(Integer.valueOf(enchantment.getInt("id"))) - : enchantment.getString("id"); - ResourceLocation id = safeId(name); + CompoundTag enchantment = list.getCompoundOrEmpty(index); + boolean numeric = zone.moddev.mc.basepower.util.NbtCompat.containsTag(enchantment, "id", 99); + String name = numeric ? savedIds.get(Integer.valueOf(enchantment.getIntOr("id", 0))) + : enchantment.getStringOr("id", ""); + Identifier id = safeId(name); if (id == null) continue; String target = LegacyMigrationApi.registryTarget(id).toString(); if (numeric || !target.equals(name)) { @@ -431,7 +466,7 @@ private static int migrateLegacyNbt(Tag value) { int changed = 0; if (value instanceof CompoundTag) { CompoundTag compound = (CompoundTag) value; - String id = compound.getString("id"); + String id = compound.getStringOr("id", ""); String vanillaBlockEntity = VANILLA_BLOCK_ENTITY_IDS.get(id); if (vanillaBlockEntity != null) { compound.putString("id", vanillaBlockEntity); @@ -447,9 +482,9 @@ private static int migrateLegacyNbt(Tag value) { changed++; } - if ("forge:bucketfilled".equalsIgnoreCase(id) && compound.contains("tag", 10)) { - CompoundTag tag = compound.getCompound("tag"); - ResourceLocation bucket = LegacyMigrationApi.bucketTarget(tag.getString("FluidName")); + if ("forge:bucketfilled".equalsIgnoreCase(id) && zone.moddev.mc.basepower.util.NbtCompat.containsTag(compound, "tag", 10)) { + CompoundTag tag = compound.getCompoundOrEmpty("tag"); + Identifier bucket = LegacyMigrationApi.bucketTarget(tag.getStringOr("FluidName", "")); if (bucket != null) { compound.putString("id", bucket.toString()); tag.remove("FluidName"); @@ -457,10 +492,10 @@ private static int migrateLegacyNbt(Tag value) { if (tag.isEmpty()) compound.remove("tag"); changed++; } - } else if (compound.contains("Count", 99)) { - ResourceLocation oldId = safeId(id); + } else if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(compound, "Count", 99)) { + Identifier oldId = safeId(id); if (oldId != null && LegacyMigrationApi.handlesNamespace(oldId.getNamespace())) { - ResourceLocation target = LegacyMigrationApi.itemTarget(oldId); + Identifier target = LegacyMigrationApi.itemTarget(oldId); if (!target.equals(oldId)) { if (!ForgeRegistries.ITEMS.containsKey(target)) { throw new IllegalStateException("No replacement for saved Advantage item " @@ -470,11 +505,11 @@ private static int migrateLegacyNbt(Tag value) { changed++; } if (LegacyMigrationApi.isDurableItem(oldId) - && compound.contains("Damage", 99) && compound.getInt("Damage") > 0) { - CompoundTag tag = compound.contains("tag", 10) - ? compound.getCompound("tag") : new CompoundTag(); - if (!tag.contains("Damage", 99)) { - tag.putInt("Damage", compound.getInt("Damage")); + && zone.moddev.mc.basepower.util.NbtCompat.containsTag(compound, "Damage", 99) && compound.getIntOr("Damage", 0) > 0) { + CompoundTag tag = zone.moddev.mc.basepower.util.NbtCompat.containsTag(compound, "tag", 10) + ? compound.getCompoundOrEmpty("tag") : new CompoundTag(); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "Damage", 99)) { + tag.putInt("Damage", compound.getIntOr("Damage", 0)); compound.put("tag", tag); changed++; } @@ -486,7 +521,7 @@ private static int migrateLegacyNbt(Tag value) { changed += migrateFluidName(compound, "FluidID"); if (migrateLegacyEnchantments(compound, LEGACY_ENCHANTMENTS)) changed++; if (LegacyMigrationApi.migrateRegisteredNbt(compound)) changed++; - for (String key : new ArrayList<>(compound.getAllKeys())) { + for (String key : new ArrayList<>(compound.keySet())) { Tag child = compound.get(key); if (child != null) changed += migrateLegacyNbt(child); } @@ -498,8 +533,8 @@ private static int migrateLegacyNbt(Tag value) { } private static int migrateFluidName(CompoundTag compound, String key) { - if (!compound.contains(key, 8)) return 0; - String oldName = compound.getString(key); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(compound, key, 8)) return 0; + String oldName = compound.getStringOr(key, ""); String target = LegacyMigrationApi.fluidTarget(oldName); if (target.equals(normalizeIdentity(oldName))) return 0; compound.putString(key, target); @@ -513,7 +548,7 @@ static File legacyRegistrySource(File levelDat) { if (!candidate.isFile()) continue; try (FileInputStream input = new FileInputStream(candidate)) { CompoundTag root = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); - if (root.contains("FML", 10) && containsLegacyRegistryEntry(root)) return candidate; + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(root, "FML", 10) && containsLegacyRegistryEntry(root)) return candidate; } catch (IOException ignored) {} } return null; @@ -524,7 +559,7 @@ static void preserveLegacyRegistry(File levelDat, CompoundTag source) throws IOE if (Files.exists(target)) return; // Forge no longer writes the uppercase FML archive. Unvisited legacy chunks still need it. CompoundTag archive = new CompoundTag(); - archive.put("FML", source.getCompound("FML").copy()); + archive.put("FML", source.getCompoundOrEmpty("FML").copy()); java.nio.file.Path temporary = Files.createTempFile(target.getParent(), "basepower-legacy-", ".tmp"); try { NbtIo.writeCompressed(archive, temporary); @@ -535,10 +570,10 @@ static void preserveLegacyRegistry(File levelDat, CompoundTag source) throws IOE } private static boolean containsLegacyRegistryEntry(CompoundTag root) { - ListTag ids = root.getCompound("FML").getCompound("Registries") - .getCompound(BLOCK_REGISTRY.toString()).getList("ids", 10); + ListTag ids = root.getCompoundOrEmpty("FML").getCompoundOrEmpty("Registries") + .getCompoundOrEmpty(BLOCK_REGISTRY.toString()).getListOrEmpty("ids"); for (int index = 0; index < ids.size(); index++) { - ResourceLocation id = safeId(normalizeLegacyRegistryName(ids.getCompound(index).getString("K"))); + Identifier id = safeId(normalizeLegacyRegistryName(ids.getCompoundOrEmpty(index).getStringOr("K", ""))); if (id != null && LegacyMigrationApi.handlesNamespace(id.getNamespace())) return true; } return false; @@ -546,12 +581,12 @@ private static boolean containsLegacyRegistryEntry(CompoundTag root) { private static int countLegacyBlocks(CompoundTag level) { int found = 0; - ListTag sections = level.getList("Sections", 10); + ListTag sections = level.getListOrEmpty("Sections"); for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) { - CompoundTag section = sections.getCompound(sectionIndex); - byte[] blocks = section.getByteArray("Blocks"); + CompoundTag section = sections.getCompoundOrEmpty(sectionIndex); + byte[] blocks = section.getByteArray("Blocks").orElseGet(() -> new byte[0]); if (blocks.length != 4096) continue; - byte[] add = section.getByteArray("Add"); + byte[] add = section.getByteArray("Add").orElseGet(() -> new byte[0]); for (int index = 0; index < blocks.length; index++) { int high = add.length == 2048 ? (add[index >> 1] >> ((index & 1) * 4)) & 15 : 0; if (LEGACY_BLOCK_IDS.get((blocks[index] & 255) | (high << 8))) found++; @@ -567,12 +602,12 @@ public static Map legacyPreparedBlockCounts() { static int indexLegacyChunks(File worldDirectory) { LEGACY_CHUNKS.clear(); - return indexLegacyChunks(ResourceLocation.parse("overworld"), new File(worldDirectory, "region")) - + indexLegacyChunks(ResourceLocation.parse("the_nether"), new File(worldDirectory, "DIM-1/region")) - + indexLegacyChunks(ResourceLocation.parse("the_end"), new File(worldDirectory, "DIM1/region")); + return 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")); } - private static int indexLegacyChunks(ResourceLocation dimension, File regionDirectory) { + 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(); @@ -662,8 +697,8 @@ private static String normalizeIdentity(String value) { return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); } - private static ResourceLocation safeId(String value) { - try { return value == null || value.isEmpty() ? null : ResourceLocation.parse(value.toLowerCase(Locale.ROOT)); } + private static Identifier safeId(String value) { + try { return value == null || value.isEmpty() ? null : Identifier.parse(value.toLowerCase(Locale.ROOT)); } catch (RuntimeException ignored) { return null; } } 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 5c26da0e..f89744a7 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadata.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadata.java @@ -13,7 +13,7 @@ import net.minecraft.nbt.NbtAccounter; import net.minecraft.nbt.StringTag; import net.minecraft.nbt.Tag; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.fml.ModList; /** Translate renamed mods before vanilla selects packs or Forge reads its saved mod list. */ @@ -38,39 +38,44 @@ public static CompoundTag remapRaw(CompoundTag root) { } static void remap(CompoundTag root, Predicate loaded) { - CompoundTag forge = root.getCompound("fml"); - CompoundTag registries = forge.getCompound("Registries"); - if (registries.contains("minecraft:motive", Tag.TAG_COMPOUND) + CompoundTag forge = root.getCompoundOrEmpty("fml"); + CompoundTag registries = forge.getCompoundOrEmpty("Registries"); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(registries, "minecraft:motive", Tag.TAG_COMPOUND) && !registries.contains("minecraft:painting_variant")) { // The registry was renamed; keep its saved IDs, including modded entries. - registries.put("minecraft:painting_variant", registries.getCompound("minecraft:motive")); + registries.put("minecraft:painting_variant", registries.getCompoundOrEmpty("minecraft:motive")); registries.remove("minecraft:motive"); } - ListTag mods = forge.getList("LoadingModList", Tag.TAG_COMPOUND); + ListTag mods = typedList(forge, "LoadingModList", CompoundTag.class); Set recordedMods = new HashSet<>(); - for (int i = 0; i < mods.size(); ++i) recordedMods.add(mods.getCompound(i).getString("ModId")); + for (int i = 0; i < mods.size(); ++i) recordedMods.add(mods.getCompoundOrEmpty(i).getStringOr("ModId", "")); for (int i = 0; i < mods.size(); ++i) { - CompoundTag mod = mods.getCompound(i); - String id = mod.getString("ModId"); + CompoundTag mod = mods.getCompoundOrEmpty(i); + String id = mod.getStringOr("ModId", ""); String target = target(id, loaded); if (target.equals(id)) continue; if (recordedMods.add(target)) mod.putString("ModId", target); else mods.remove(i--); } - CompoundTag packs = root.getCompound("Data").getCompound("DataPacks"); - ListTag enabled = packs.getList("Enabled", Tag.TAG_STRING); - ListTag disabled = packs.getList("Disabled", Tag.TAG_STRING); + CompoundTag packs = root.getCompoundOrEmpty("Data").getCompoundOrEmpty("DataPacks"); + ListTag enabled = typedList(packs, "Enabled", StringTag.class); + ListTag disabled = typedList(packs, "Disabled", StringTag.class); Set recordedPacks = new HashSet<>(); - enabled.forEach(pack -> recordedPacks.add(pack.getAsString())); - disabled.forEach(pack -> recordedPacks.add(pack.getAsString())); + enabled.forEach(pack -> recordedPacks.add(((StringTag)pack).value())); + disabled.forEach(pack -> recordedPacks.add(((StringTag)pack).value())); remapPacks(enabled, recordedPacks, loaded); remapPacks(disabled, recordedPacks, loaded); } + private static ListTag typedList(CompoundTag owner, String key, Class elementType) { + return owner.getList(key).filter(values -> values.stream().allMatch(elementType::isInstance)) + .orElseGet(ListTag::new); + } + private static void remapPacks(ListTag packs, Set recorded, Predicate loaded) { for (int i = 0; i < packs.size(); ++i) { - String pack = packs.getString(i); + String pack = packs.getStringOr(i, ""); if (!pack.startsWith("mod:")) continue; String replacement = "mod:" + target(pack.substring(4), loaded); if (replacement.equals(pack)) continue; @@ -81,7 +86,7 @@ private static void remapPacks(ListTag packs, Set recorded, Predicate loaded) { if (id.isEmpty() || id.indexOf(':') >= 0) return id; - ResourceLocation key = ResourceLocation.tryParse(id + ":metadata"); + Identifier key = Identifier.tryParse(id + ":metadata"); if (key == null) return id; String replacement = LegacyMigrationApi.registryTarget(key).getNamespace(); return !replacement.equals(id) && loaded.test(replacement) && !loaded.test(id) ? replacement : id; diff --git a/src/main/java/zone/moddev/mc/basepower/migration/VanillaRegistryRemaps.java b/src/main/java/zone/moddev/mc/basepower/migration/VanillaRegistryRemaps.java index 4407dd7b..d0f1193c 100644 --- a/src/main/java/zone/moddev/mc/basepower/migration/VanillaRegistryRemaps.java +++ b/src/main/java/zone/moddev/mc/basepower/migration/VanillaRegistryRemaps.java @@ -3,6 +3,7 @@ import java.util.function.UnaryOperator; import java.util.Set; import cyano.poweradvantage.api.migration.LegacyMigrationApi; +import net.minecraft.core.registries.Registries; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.ai.memory.MemoryModuleType; import net.minecraft.world.item.Item; @@ -11,12 +12,14 @@ import net.minecraft.world.level.material.Fluid; import net.minecraft.world.inventory.MenuType; import net.minecraft.world.item.crafting.RecipeSerializer; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; import net.minecraft.sounds.SoundEvent; import net.minecraft.util.datafix.fixes.CavesAndCliffsRenames; import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.Biomes; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.listener.Priority; import net.minecraftforge.registries.MissingMappingsEvent; import net.minecraftforge.registries.IForgeRegistry; import net.minecraftforge.registries.ForgeRegistries; @@ -24,28 +27,53 @@ /** Vanilla's data fixers rename saved data, but not Forge's registry snapshot. */ public final class VanillaRegistryRemaps { - private static final ResourceLocation BIOME_REGISTRY = ResourceLocation.parse("minecraft:worldgen/biome"); + private static final Identifier BIOME_REGISTRY = Identifier.parse("minecraft:worldgen/biome"); + // Data-registry keys only: Forge's static biome registry has no bound native objects. + private static final Set> NATIVE_BIOMES = Set.of( + Biomes.THE_VOID, Biomes.PLAINS, Biomes.SUNFLOWER_PLAINS, Biomes.SNOWY_PLAINS, + Biomes.ICE_SPIKES, Biomes.DESERT, Biomes.SWAMP, Biomes.MANGROVE_SWAMP, + Biomes.FOREST, Biomes.FLOWER_FOREST, Biomes.BIRCH_FOREST, Biomes.DARK_FOREST, + Biomes.PALE_GARDEN, Biomes.OLD_GROWTH_BIRCH_FOREST, Biomes.OLD_GROWTH_PINE_TAIGA, + Biomes.OLD_GROWTH_SPRUCE_TAIGA, Biomes.TAIGA, Biomes.SNOWY_TAIGA, Biomes.SAVANNA, + Biomes.SAVANNA_PLATEAU, Biomes.WINDSWEPT_HILLS, Biomes.WINDSWEPT_GRAVELLY_HILLS, + Biomes.WINDSWEPT_FOREST, Biomes.WINDSWEPT_SAVANNA, Biomes.JUNGLE, Biomes.SPARSE_JUNGLE, + Biomes.BAMBOO_JUNGLE, Biomes.BADLANDS, Biomes.ERODED_BADLANDS, Biomes.WOODED_BADLANDS, + Biomes.MEADOW, Biomes.CHERRY_GROVE, Biomes.GROVE, Biomes.SNOWY_SLOPES, + Biomes.FROZEN_PEAKS, Biomes.JAGGED_PEAKS, Biomes.STONY_PEAKS, Biomes.RIVER, + Biomes.FROZEN_RIVER, Biomes.BEACH, Biomes.SNOWY_BEACH, Biomes.STONY_SHORE, + Biomes.WARM_OCEAN, Biomes.LUKEWARM_OCEAN, Biomes.DEEP_LUKEWARM_OCEAN, Biomes.OCEAN, + Biomes.DEEP_OCEAN, Biomes.COLD_OCEAN, Biomes.DEEP_COLD_OCEAN, Biomes.FROZEN_OCEAN, + Biomes.DEEP_FROZEN_OCEAN, Biomes.MUSHROOM_FIELDS, Biomes.DRIPSTONE_CAVES, + Biomes.LUSH_CAVES, Biomes.DEEP_DARK, Biomes.NETHER_WASTES, Biomes.WARPED_FOREST, + Biomes.CRIMSON_FOREST, Biomes.SOUL_SAND_VALLEY, Biomes.BASALT_DELTAS, Biomes.THE_END, + Biomes.END_HIGHLANDS, Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS); private static final Set LEGACY_PAINTINGS = Set.of("kebab", "aztec", "alban", "aztec2", "bomb", "plant", "wasteland", "pool", "courbet", "sea", "sunset", "creebet", "wanderer", "graham", "match", "bust", "stage", "void", "skull_and_roses", "wither", "fighters", "pointer", "pigscene", "burning_skull", "skeleton", "donkey_kong", "earth", "wind", "water", "fire"); + private static final Set SPLIT_ENTITY_INDEXES = Set.of("boat", "chest_boat", "potion"); + private static final Set REMOVED_SOUND_INDEXES = Set.of("entity.goat.screaming.horn_break", + "entity.leash_knot.break", "entity.leash_knot.place", "entity.puffer_fish.ambient", + "entity.wolf.howl", "item.goat_horn.play", "entity.parrot.imitate.enderman", + "entity.parrot.imitate.polar_bear", "entity.parrot.imitate.wolf", + "entity.parrot.imitate.zombie_pigman"); private VanillaRegistryRemaps() {} public static void register() { - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::blocks); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::items); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::entities); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::sounds); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::biomes); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::memories); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::pointsOfInterest); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::blockEntities); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::fluids); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::potions); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::menus); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::recipeSerializers); - MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, VanillaRegistryRemaps::paintings); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::blocks); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::items); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::entities); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::sounds); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::biomes); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::memories); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::pointsOfInterest); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::blockEntities); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::fluids); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::potions); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::menus); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::recipeSerializers); + MissingMappingsEvent.BUS.addListener(Priority.HIGHEST, VanillaRegistryRemaps::paintings); } private static void blocks(MissingMappingsEvent event) { @@ -59,7 +87,7 @@ private static void items(MissingMappingsEvent event) { private static void entities(MissingMappingsEvent event) { remap(event, ForgeRegistries.ENTITY_TYPES); remapAdvantage(event, ForgeRegistries.ENTITY_TYPES, - id -> ResourceLocation.parse(LegacyMigrationApi.entityTarget(id.toString()))); + id -> Identifier.parse(LegacyMigrationApi.entityTarget(id.toString()))); } private static void sounds(MissingMappingsEvent event) { remap(event, ForgeRegistries.SOUND_EVENTS); } private static void paintings(MissingMappingsEvent event) { remap(event, ForgeRegistries.PAINTING_VARIANTS); } @@ -69,11 +97,11 @@ private static void entities(MissingMappingsEvent event) { private static void blockEntities(MissingMappingsEvent event) { remapAdvantage(event, ForgeRegistries.BLOCK_ENTITY_TYPES, - id -> ResourceLocation.parse(LegacyMigrationApi.tileTarget(id.toString()))); + id -> Identifier.parse(LegacyMigrationApi.tileTarget(id.toString()))); } private static void fluids(MissingMappingsEvent event) { remapAdvantage(event, ForgeRegistries.FLUIDS, - id -> ResourceLocation.parse(LegacyMigrationApi.fluidTarget(id.toString()))); + id -> Identifier.parse(LegacyMigrationApi.fluidTarget(id.toString()))); } private static void potions(MissingMappingsEvent event) { remap(event, ForgeRegistries.POTIONS); } private static void menus(MissingMappingsEvent event) { @@ -84,9 +112,9 @@ private static void recipeSerializers(MissingMappingsEvent event) { } private static void remapAdvantage(MissingMappingsEvent event, - IForgeRegistry active, UnaryOperator replacement) { + IForgeRegistry active, UnaryOperator replacement) { for (MissingMappingsEvent.Mapping mapping : event.getAllMappings(active.getRegistryKey())) { - ResourceLocation target = replacement.apply(mapping.getKey()); + Identifier target = replacement.apply(mapping.getKey()); if (!mapping.getKey().equals(target) && active.containsKey(target)) { mapping.remap(active.getValue(target)); PowerAdvantage.LOGGER.info("Migrated Advantage registry entry {} to {}", mapping.getKey(), target); @@ -102,8 +130,8 @@ static void remap(MissingMappingsEvent event, IForgeRegistry active) { mapping.ignore(); continue; } - if (active.getRegistryName().equals(ResourceLocation.parse("minecraft:potion")) - && mapping.getKey().equals(ResourceLocation.parse("minecraft:empty"))) { + if (active.getRegistryName().equals(Identifier.parse("minecraft:potion")) + && mapping.getKey().equals(Identifier.parse("minecraft:empty"))) { // An absent potion is now represented by empty potion contents, not a registry entry. mapping.ignore(); continue; @@ -122,16 +150,25 @@ static void remap(MissingMappingsEvent event, IForgeRegistry active) { PowerAdvantage.LOGGER.info("Retired unused vanilla point of interest index {}", mapping.getKey()); continue; } - ResourceLocation updatedBiome = vanillaBiomeFixTarget(active.getRegistryName(), mapping.getKey()); - if (updatedBiome != null && active.containsKey(updatedBiome)) { - // Vanilla fixes biome names in chunks and world settings. Several - // old entries share one target; do not assign it multiple numeric IDs. + if (retiredNativeIndex(active.getRegistryName(), mapping.getKey())) { + // BoatSplitFix and ThrownPotionSplitFix select the concrete type + // from entity NBT. Reusing one type here would collapse variants. + // Removed sounds have no persistent world payload to translate. + mapping.ignore(); + PowerAdvantage.LOGGER.info("Retired obsolete vanilla registry index {} from {}", + mapping.getKey(), active.getRegistryName()); + continue; + } + Identifier updatedBiome = vanillaBiomeIndexTarget(active.getRegistryName(), mapping.getKey()); + if (updatedBiome != null) { + // Native data registries retain current names and DFU fixes old names + // in terrain/settings. Retire only Forge's obsolete numeric index. mapping.ignore(); PowerAdvantage.LOGGER.info("Retired vanilla biome registry index {} (data fixer: {})", mapping.getKey(), updatedBiome); continue; } - ResourceLocation replacement = replacement(active.getRegistryName(), mapping.getKey()); + Identifier replacement = replacement(active.getRegistryName(), mapping.getKey()); if (replacement == null) continue; // The event's registry is STAGING: new entries need not be present there yet. if (!active.containsKey(replacement)) { @@ -143,47 +180,64 @@ static void remap(MissingMappingsEvent event, IForgeRegistry active) { } } - static boolean retiredPaintingIndex(ResourceLocation registry, ResourceLocation old) { - return registry.equals(ResourceLocation.parse("minecraft:painting_variant")) + static boolean retiredPaintingIndex(Identifier registry, Identifier old) { + return registry.equals(Identifier.parse("minecraft:painting_variant")) && old.getNamespace().equals("minecraft") && LEGACY_PAINTINGS.contains(old.getPath()); } - static boolean retiredTransientMemory(ResourceLocation registry, ResourceLocation old) { - return registry.equals(ResourceLocation.parse("minecraft:memory_module_type")) + static boolean retiredTransientMemory(Identifier registry, Identifier old) { + return registry.equals(Identifier.parse("minecraft:memory_module_type")) && old.getNamespace().equals("minecraft") && (old.getPath().equals("golem_last_seen_time") || old.getPath().equals("opened_doors")); } - static boolean retiredVanillaPoi(ResourceLocation registry, ResourceLocation old) { - return registry.equals(ResourceLocation.parse("minecraft:point_of_interest_type")) + static boolean retiredVanillaPoi(Identifier registry, Identifier old) { + return registry.equals(Identifier.parse("minecraft:point_of_interest_type")) && old.getNamespace().equals("minecraft") && (old.getPath().equals("nitwit") || old.getPath().equals("unemployed")); } - static ResourceLocation vanillaBiomeFixTarget(ResourceLocation registry, ResourceLocation old) { + private static boolean retiredNativeIndex(Identifier registry, Identifier old) { + if (!old.getNamespace().equals("minecraft")) return false; + return registry.equals(Identifier.parse("minecraft:entity_type")) && SPLIT_ENTITY_INDEXES.contains(old.getPath()) + || registry.equals(Identifier.parse("minecraft:sound_event")) && REMOVED_SOUND_INDEXES.contains(old.getPath()); + } + + static Identifier vanillaBiomeFixTarget(Identifier registry, Identifier old) { if (!BIOME_REGISTRY.equals(registry) || !"minecraft".equals(old.getNamespace())) return null; String name = old.toString(); - String updated = name.equals("minecraft:deep_warm_ocean") ? "minecraft:warm_ocean" + String updated = name.equals("minecraft:nether") ? Biomes.NETHER_WASTES.identifier().toString() + : name.equals("minecraft:deep_warm_ocean") ? Biomes.WARM_OCEAN.identifier().toString() : CavesAndCliffsRenames.RENAMES.get(name); - return updated == null ? null : ResourceLocation.parse(updated); + return updated == null ? null : Identifier.parse(updated); + } + + private static Identifier vanillaBiomeIndexTarget(Identifier registry, Identifier old) { + if (!BIOME_REGISTRY.equals(registry) || !"minecraft".equals(old.getNamespace())) return null; + Identifier renamed = vanillaBiomeFixTarget(registry, old); + Identifier target = renamed == null ? old : renamed; + return NATIVE_BIOMES.contains(ResourceKey.create(Registries.BIOME, target)) ? target : null; } - static ResourceLocation replacement(ResourceLocation registry, ResourceLocation old) { + static Identifier replacement(Identifier registry, Identifier old) { if (!"minecraft".equals(old.getNamespace())) return null; String target = null; - if ((registry.equals(ResourceLocation.parse("minecraft:block")) || registry.equals(ResourceLocation.parse("minecraft:item"))) + if ((registry.equals(Identifier.parse("minecraft:block")) || registry.equals(Identifier.parse("minecraft:item"))) && old.getPath().equals("grass_path")) { target = "dirt_path"; - } else if ((registry.equals(ResourceLocation.parse("minecraft:block")) || registry.equals(ResourceLocation.parse("minecraft:item"))) + } else if ((registry.equals(Identifier.parse("minecraft:block")) || registry.equals(Identifier.parse("minecraft:item"))) && old.getPath().equals("grass")) { target = "short_grass"; - } else if (registry.equals(ResourceLocation.parse("minecraft:enchantment")) && old.getPath().equals("sweeping")) { + } else if ((registry.equals(Identifier.parse("minecraft:block")) || registry.equals(Identifier.parse("minecraft:item"))) + && old.getPath().equals("chain")) { + target = "iron_chain"; + } else if (registry.equals(Identifier.parse("minecraft:enchantment")) && old.getPath().equals("sweeping")) { target = "sweeping_edge"; - } else if (registry.equals(ResourceLocation.parse("minecraft:entity_type")) && old.getPath().equals("zombie_pigman")) { + } else if (registry.equals(Identifier.parse("minecraft:entity_type")) && old.getPath().equals("zombie_pigman")) { target = "zombified_piglin"; - } else if (registry.equals(ResourceLocation.parse("minecraft:item")) && old.getPath().equals("zombie_pigman_spawn_egg")) { + } else if (registry.equals(Identifier.parse("minecraft:item")) && old.getPath().equals("zombie_pigman_spawn_egg")) { target = "zombified_piglin_spawn_egg"; - } else if (registry.equals(ResourceLocation.parse("minecraft:item"))) { + } else if (registry.equals(Identifier.parse("minecraft:item"))) { // Forge keeps the 1.19.4 experimental IDs even when that data pack was not enabled. target = switch (old.getPath()) { case "pottery_shard_archer" -> "archer_pottery_sherd"; @@ -193,9 +247,9 @@ static ResourceLocation replacement(ResourceLocation registry, ResourceLocation case "scute" -> "turtle_scute"; default -> null; }; - } else if (registry.equals(ResourceLocation.parse("minecraft:worldgen/biome")) && old.getPath().equals("nether")) { + } else if (registry.equals(Identifier.parse("minecraft:worldgen/biome")) && old.getPath().equals("nether")) { target = "nether_wastes"; - } else if (registry.equals(ResourceLocation.parse("minecraft:sound_event"))) { + } else if (registry.equals(Identifier.parse("minecraft:sound_event"))) { if (old.getPath().equals("block.trial_spawner.ambient_charged")) target = "block.trial_spawner.ambient_ominous"; if (old.getPath().equals("block.trial_spawner.charge_activate")) target = "block.trial_spawner.ominous_activate"; for (String sound : new String[] {"ambient", "angry", "death", "hurt"}) { @@ -209,6 +263,6 @@ static ResourceLocation replacement(ResourceLocation registry, ResourceLocation // Native biomes choose their own new music; this alias is for the old shared registry ID. if (old.getPath().equals("music.overworld.jungle_and_forest")) target = "music.overworld.forest"; } - return target == null ? null : ResourceLocation.fromNamespaceAndPath("minecraft", target); + return target == null ? null : Identifier.fromNamespaceAndPath("minecraft", target); } } diff --git a/src/main/java/zone/moddev/mc/basepower/mixin/BlockStateDataMixin.java b/src/main/java/zone/moddev/mc/basepower/mixin/BlockStateDataMixin.java new file mode 100644 index 00000000..19814056 --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/mixin/BlockStateDataMixin.java @@ -0,0 +1,25 @@ +package zone.moddev.mc.basepower.mixin; + +import java.util.Arrays; +import com.mojang.serialization.Dynamic; +import net.minecraft.util.datafix.fixes.BlockStateData; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +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.CallbackInfo; + +/** Leaves room for the saved Forge numeric IDs before vanilla flattens them. */ +@Mixin(BlockStateData.class) +abstract class BlockStateDataMixin { + @Shadow @Final @Mutable private static Dynamic[] MAP; + @Shadow @Final @Mutable private static Dynamic[] BLOCK_DEFAULTS; + + @Inject(method = "", at = @At("TAIL")) + private static void basepower$expandLegacyIds(CallbackInfo callback) { + if (MAP.length < 65536) MAP = Arrays.copyOf(MAP, 65536); + if (BLOCK_DEFAULTS.length < 4096) BLOCK_DEFAULTS = Arrays.copyOf(BLOCK_DEFAULTS, 4096); + } +} diff --git a/src/main/java/zone/moddev/mc/basepower/mixin/LevelStorageSourceMixin.java b/src/main/java/zone/moddev/mc/basepower/mixin/LevelStorageSourceMixin.java new file mode 100644 index 00000000..1ee41ac0 --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/mixin/LevelStorageSourceMixin.java @@ -0,0 +1,48 @@ +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. */ +@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())); + } + + @Inject(method = "readLightweightData", at = @At("RETURN"), cancellable = true) + private static void basepower$remapSummary(Path file, CallbackInfoReturnable callback) { + 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/PlayerDataStorageMixin.java b/src/main/java/zone/moddev/mc/basepower/mixin/PlayerDataStorageMixin.java new file mode 100644 index 00000000..62385594 --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/mixin/PlayerDataStorageMixin.java @@ -0,0 +1,22 @@ +package zone.moddev.mc.basepower.mixin; + +import com.mojang.datafixers.DataFixer; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.world.level.storage.PlayerDataStorage; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; +import zone.moddev.mc.basepower.migration.LegacyPlayerDataFixes; + +/** Preserves modded wear and enchantments in loose player files. */ +@Mixin(PlayerDataStorage.class) +abstract class PlayerDataStorageMixin { + @Redirect(method = "*", at = @At(value = "INVOKE", target = + "Lnet/minecraft/util/datafix/DataFixTypes;updateToCurrentVersion(Lcom/mojang/datafixers/DataFixer;Lnet/minecraft/nbt/CompoundTag;I)Lnet/minecraft/nbt/CompoundTag;"), + require = 1, allow = 1) + private CompoundTag basepower$preservePlayer(DataFixTypes type, DataFixer fixer, + CompoundTag source, int version) { + return LegacyPlayerDataFixes.updateNativePlayer(type, fixer, source, version); + } +} diff --git a/src/main/java/zone/moddev/mc/basepower/mixin/SimpleRegionStorageMixin.java b/src/main/java/zone/moddev/mc/basepower/mixin/SimpleRegionStorageMixin.java new file mode 100644 index 00000000..38f390cc --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/mixin/SimpleRegionStorageMixin.java @@ -0,0 +1,45 @@ +package zone.moddev.mc.basepower.mixin; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.storage.SimpleRegionStorage; +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.callback.CallbackInfoReturnable; +import zone.moddev.mc.basepower.migration.LegacyWorldDataHook; + +/** Works with Base Metals' hooks without mutating pending-save cache entries. */ +@Mixin(SimpleRegionStorage.class) +abstract class SimpleRegionStorageMixin { + @Inject(method = "read", at = @At("RETURN"), cancellable = true) + private void basepower$detachRead(ChunkPos pos, + CallbackInfoReturnable>> callback) { + callback.setReturnValue(LegacyWorldDataHook.copyChunkReadForMigration(callback.getReturnValue())); + } + + @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;)Lnet/minecraft/nbt/CompoundTag;", + at = @At("HEAD")) + private void basepower$prepareChunk(CompoundTag chunk, int fallback, CompoundTag context, + CallbackInfoReturnable callback) { + LegacyWorldDataHook.prepareLegacyChunk(chunk); + } + + @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;)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, + 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;", + at = @At("RETURN")) + private void basepower$finishChunk(CompoundTag chunk, int fallback, CompoundTag context, + 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/mixin/WorldGenRegionMixin.java b/src/main/java/zone/moddev/mc/basepower/mixin/WorldGenRegionMixin.java new file mode 100644 index 00000000..77c1bd52 --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/mixin/WorldGenRegionMixin.java @@ -0,0 +1,20 @@ +package zone.moddev.mc.basepower.mixin; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.WorldGenRegion; +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.callback.CallbackInfoReturnable; +import zone.moddev.mc.basepower.migration.LegacyWorldDataHook; + +/** Stops new decoration replacing old, already-generated workshop terrain. */ +@Mixin(WorldGenRegion.class) +abstract class WorldGenRegionMixin { + @Inject(method = "ensureCanWrite", at = @At("HEAD"), cancellable = true) + private void basepower$protectTerrain(BlockPos pos, CallbackInfoReturnable callback) { + if (LegacyWorldDataHook.shouldBlockWorldgenWrite((WorldGenRegion) (Object) this, pos)) { + callback.setReturnValue(false); + } + } +} 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 cd291e48..e2d84d0a 100644 --- a/src/main/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPack.java +++ b/src/main/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPack.java @@ -11,11 +11,11 @@ import java.util.Optional; import java.util.Set; import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.server.packs.PackResources; import net.minecraft.server.packs.PackLocationInfo; import net.minecraft.server.packs.PackType; -import net.minecraft.server.packs.metadata.MetadataSectionSerializer; +import net.minecraft.server.packs.metadata.MetadataSectionType; import net.minecraft.server.packs.metadata.pack.PackMetadataSection; import net.minecraft.server.packs.resources.IoSupplier; import net.minecraft.server.packs.repository.PackSource; @@ -29,7 +29,7 @@ final class MaterialPolicyPack implements PackResources { static final String NAME = "Power Advantage material aliases"; static final PackLocationInfo LOCATION = new PackLocationInfo(ID, Component.literal(NAME), PackSource.BUILT_IN, Optional.empty()); - private final Map resources = new LinkedHashMap<>(); + private final Map resources = new LinkedHashMap<>(); MaterialPolicyPack() { // Pack instances are created for each reload, after common config loading. @@ -58,19 +58,19 @@ private void put(String path, JsonArray values) { JsonObject tag = new JsonObject(); tag.addProperty("replace", false); tag.add("values", values); - resources.put(ResourceLocation.fromNamespaceAndPath("forge", "tags/item/" + path + ".json"), tag.toString()); + resources.put(Identifier.fromNamespaceAndPath("forge", "tags/item/" + path + ".json"), tag.toString()); } @Override public IoSupplier getRootResource(String... names) { return null; } - @Override public IoSupplier getResource(PackType type, ResourceLocation id) { + @Override public IoSupplier getResource(PackType type, Identifier id) { if (type != PackType.SERVER_DATA || !resources.containsKey(id)) return null; return () -> new ByteArrayInputStream(resources.get(id).getBytes(StandardCharsets.UTF_8)); } @Override public void listResources(PackType type, String namespace, String prefix, ResourceOutput output) { if (type != PackType.SERVER_DATA) return; - for (ResourceLocation id : resources.keySet()) { + for (Identifier id : resources.keySet()) { if (namespace.equals(id.getNamespace()) && id.getPath().startsWith(prefix + "/")) { output.accept(id, getResource(type, id)); } @@ -80,9 +80,11 @@ private void put(String path, JsonArray values) { return type == PackType.SERVER_DATA ? Collections.singleton("forge") : Collections.emptySet(); } @Override @SuppressWarnings("unchecked") - public T getMetadataSection(MetadataSectionSerializer serializer) { - return serializer == PackMetadataSection.TYPE - ? (T)new PackMetadataSection(Component.literal(NAME), 48, Optional.empty()) : null; + public T getMetadataSection(MetadataSectionType section) { + return section == PackMetadataSection.SERVER_TYPE + ? (T)new PackMetadataSection(Component.literal(NAME), + new net.minecraft.util.InclusiveRange<>(net.minecraft.SharedConstants.getCurrentVersion() + .packVersion(PackType.SERVER_DATA))) : null; } @Override public PackLocationInfo location() { return LOCATION; } @Override public void close() {} 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 39c6e7f4..5b2211a8 100644 --- a/src/main/java/zone/moddev/mc/basepower/recipe/PartsLoot.java +++ b/src/main/java/zone/moddev/mc/basepower/recipe/PartsLoot.java @@ -8,14 +8,14 @@ import java.util.function.Predicate; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.Resource; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.storage.loot.LootContext; import net.minecraft.world.level.storage.loot.predicates.LootItemCondition; import net.minecraftforge.common.loot.IGlobalLootModifier; import net.minecraftforge.common.loot.LootModifier; -import net.minecraftforge.eventbus.api.IEventBus; +import net.minecraftforge.eventbus.api.bus.BusGroup; import net.minecraftforge.fml.ModList; import net.minecraftforge.registries.DeferredRegister; import net.minecraftforge.registries.ForgeRegistries; @@ -36,35 +36,36 @@ public final class PartsLoot extends LootModifier { private PartsLoot(LootItemCondition[] conditions) { super(conditions); } - public static void register(IEventBus bus) { SERIALIZERS.register(bus); } + public static void register(BusGroup bus) { SERIALIZERS.register(bus); } @Override public MapCodec codec() { return CODEC; } @Override - protected ObjectArrayList doApply(ObjectArrayList loot, LootContext context) { - ResourceLocation table = context.getQueriedLootTableId(); + protected ObjectArrayList doApply(net.minecraft.world.level.storage.loot.LootTable tableSource, + ObjectArrayList loot, LootContext context) { + Identifier table = context.getQueriedLootTableId(); if (!isChest(table) || PowerAdvantage.chestLootFactor <= 0 || hasAdditionalLootTables()) return loot; // Keep data-pack replacements authoritative instead of adding parts to their loot. var resourceManager = context.getLevel().getServer().getResourceManager(); String source = resourceManager.getResource(table.withPath(path -> "loot_table/" + path + ".json")) .map(Resource::sourcePackId).orElse(""); - for (ResourceLocation extra : partTables(table, source, ModList.get()::isLoaded)) { + for (Identifier extra : partTables(table, source, ModList.get()::isLoaded)) { var key = ResourceKey.create(Registries.LOOT_TABLE, extra); - context.getResolver().get(Registries.LOOT_TABLE, key) + context.getResolver().lookupOrThrow(Registries.LOOT_TABLE).get(key) .ifPresent(holder -> holder.value().getRandomItemsRaw(context, loot::add)); } return loot; } - static List partTables(ResourceLocation table, String source, Predicate loaded) { + static List partTables(Identifier table, String source, Predicate loaded) { if (!isChest(table) || !"vanilla".equals(source)) return List.of(); return MODS.stream() .filter(mod -> loaded.test(mod) && !(mod.equals("basepower") && table.getPath().equals("chests/end_city_treasure"))) - .map(mod -> ResourceLocation.fromNamespaceAndPath(mod, "chests/advantage_parts")).toList(); + .map(mod -> Identifier.fromNamespaceAndPath(mod, "chests/advantage_parts")).toList(); } - private static boolean isChest(ResourceLocation table) { + private static boolean isChest(Identifier table) { return table != null && "minecraft".equals(table.getNamespace()) && CHESTS.contains(table.getPath()); } diff --git a/src/main/java/zone/moddev/mc/basepower/recipe/RecipeConditions.java b/src/main/java/zone/moddev/mc/basepower/recipe/RecipeConditions.java index 74eb3c31..38abe025 100644 --- a/src/main/java/zone/moddev/mc/basepower/recipe/RecipeConditions.java +++ b/src/main/java/zone/moddev/mc/basepower/recipe/RecipeConditions.java @@ -4,10 +4,10 @@ import com.mojang.serialization.DynamicOps; import com.mojang.serialization.MapCodec; import java.util.Locale; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.tags.ItemTags; import net.minecraftforge.common.crafting.conditions.ICondition; -import net.minecraftforge.eventbus.api.IEventBus; +import net.minecraftforge.eventbus.api.bus.BusGroup; import net.minecraftforge.registries.DeferredRegister; import net.minecraftforge.registries.ForgeRegistries; import zone.moddev.mc.basepower.PowerAdvantage; @@ -27,7 +27,7 @@ public final class RecipeConditions { private RecipeConditions() {} - public static void register(IEventBus bus) { SERIALIZERS.register(bus); } + public static void register(BusGroup bus) { SERIALIZERS.register(bus); } private record Mode(String mode, boolean match) implements ICondition { private static final MapCodec MATCH = create(true); @@ -43,12 +43,12 @@ private static MapCodec create(boolean match) { } } - private record Material(ResourceLocation tag, boolean empty) implements ICondition { + private record Material(Identifier tag, boolean empty) implements ICondition { private static final MapCodec EMPTY = create(true); private static final MapCodec NOT_EMPTY = create(false); private static MapCodec create(boolean empty) { - return ResourceLocation.CODEC.fieldOf("tag").xmap(id -> new Material(id, empty), Material::tag); + return Identifier.CODEC.fieldOf("tag").xmap(id -> new Material(id, empty), Material::tag); } @Override public MapCodec codec() { return empty ? EMPTY : NOT_EMPTY; } diff --git a/src/main/java/zone/moddev/mc/basepower/recipe/RecipeDiscovery.java b/src/main/java/zone/moddev/mc/basepower/recipe/RecipeDiscovery.java index c6f026bf..8ee957ee 100644 --- a/src/main/java/zone/moddev/mc/basepower/recipe/RecipeDiscovery.java +++ b/src/main/java/zone/moddev/mc/basepower/recipe/RecipeDiscovery.java @@ -7,7 +7,7 @@ import net.minecraft.advancements.AdvancementHolder; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.crafting.RecipeHolder; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.event.entity.player.PlayerEvent; /** Retains earned discoveries when recipe modes or material providers change. */ @@ -21,11 +21,11 @@ public static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { if (!(event.getEntity() instanceof ServerPlayer player)) return; // Disabled recipes have no reward to grant. Replay only already-earned // discoveries when that exact recipe becomes available on a later login. - for (RecipeHolder recipe : player.getServer().getRecipeManager().getRecipes()) { - if (!NAMESPACES.contains(recipe.id().getNamespace()) || recipe.value().isSpecial() - || player .getRecipeBook().contains(recipe)) continue; - AdvancementHolder advancement = player.getServer().getAdvancements().get( - ResourceLocation.fromNamespaceAndPath(recipe.id().getNamespace(), "recipes/" + recipe.id().getPath())); + for (RecipeHolder recipe : player.level().getServer().getRecipeManager().getRecipes()) { + if (!NAMESPACES.contains(recipe.id().identifier().getNamespace()) || recipe.value().isSpecial() + || player .getRecipeBook().contains(recipe.id())) continue; + AdvancementHolder advancement = player.level().getServer().getAdvancements().get( + Identifier.fromNamespaceAndPath(recipe.id().identifier().getNamespace(), "recipes/" + recipe.id().identifier().getPath())); if (advancement != null && player .getAdvancements().getOrStartProgress(advancement).isDone()) { player .awardRecipes(Collections.singletonList(recipe)); } 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 fdef43ca..607050f4 100644 --- a/src/main/java/zone/moddev/mc/basepower/registry/FuelRegistry.java +++ b/src/main/java/zone/moddev/mc/basepower/registry/FuelRegistry.java @@ -142,8 +142,12 @@ public static short getActualBurntimeForItem(ItemStack fuel){ if(fuel == null || fuel.isEmpty()) return 0; int custom = getInstance().getBurnTime(fuel); if (custom > 0) return (short) Math.min(Short.MAX_VALUE, custom); - Integer vanilla = AbstractFurnaceBlockEntity.getFuel().get(fuel.getItem()); - int burnTime = ForgeEventFactory.getItemBurnTime(fuel, vanilla == null ? 0 : vanilla.intValue(), net.minecraft.world.item.crafting.RecipeType.SMELTING); + var server = net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer(); + var values = server != null ? server.fuelValues() + : net.minecraft.world.level.block.entity.FuelValues.vanillaBurnTimes( + net.minecraft.core.RegistryAccess.fromRegistryOfRegistries(net.minecraft.core.registries.BuiltInRegistries.REGISTRY), + net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS); + int burnTime = values.burnDuration(fuel, net.minecraft.world.item.crafting.RecipeType.SMELTING); return (short) Math.min(Short.MAX_VALUE, Math.max(0, burnTime)); } @@ -159,7 +163,7 @@ public static ItemStack decrementFuelItem(ItemStack fuelItem){ if(list.isEmpty()){ fuelItem.shrink(1); if(fuelItem.isEmpty()){ - fuelItem = fuelItem.getCraftingRemainingItem(); + fuelItem = fuelItem.getCraftingRemainder(); } } else { for (Function algorithm : list) { 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 d3418d8c..126906a6 100644 --- a/src/main/java/zone/moddev/mc/basepower/registry/MachineGUIRegistry.java +++ b/src/main/java/zone/moddev/mc/basepower/registry/MachineGUIRegistry.java @@ -19,7 +19,7 @@ import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.common.extensions.IForgeMenuType; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.registries.ForgeRegistries; import net.minecraftforge.registries.RegisterEvent; import zone.moddev.mc.basepower.PowerAdvantage; @@ -50,7 +50,7 @@ public static int addGUI(ITileEntityGUI gui) { public static void registerTypes(RegisterEvent event) { if (!ForgeRegistries.Keys.MENU_TYPES.equals(event.getRegistryKey())) return; TYPE = IForgeMenuType.create(MachineGUIRegistry::createClientContainer); - event.register(ForgeRegistries.Keys.MENU_TYPES, ResourceLocation.fromNamespaceAndPath(PowerAdvantage.MODID, "machine"), () -> TYPE); + event.register(ForgeRegistries.Keys.MENU_TYPES, Identifier.fromNamespaceAndPath(PowerAdvantage.MODID, "machine"), () -> TYPE); } private static SimpleMachineGUI.Container createClientContainer( @@ -82,7 +82,7 @@ public static void registerScreens() { public static void open(ServerPlayer player, Level world, BlockPos pos, int id) { BlockEntity tile = world.getBlockEntity(pos); - if (world.isClientSide || player.level() != world || !(tile instanceof Container)) return; + if (world.isClientSide() || player.level() != world || !(tile instanceof Container)) return; SimpleMachineGUI gui = definition(id); player.openMenu(new MenuProvider() { @Override public AbstractContainerMenu createMenu(int windowId, Inventory inventory, Player owner) { diff --git a/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/DistillationRecipeRegistry.java b/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/DistillationRecipeRegistry.java index 5015b2a6..6e6b3aaa 100644 --- a/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/DistillationRecipeRegistry.java +++ b/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/DistillationRecipeRegistry.java @@ -10,7 +10,7 @@ import zone.moddev.mc.basepower.PowerAdvantage; import net.minecraft.world.level.material.Fluid; import cyano.poweradvantage.api.fluid.FluidStack; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; import zone.moddev.mc.basepower.init.Fluids; @@ -137,7 +137,7 @@ public static void addDistillationRecipe(FluidStack input, FluidStack output){ } private static String id(Fluid fluid) { - ResourceLocation id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); + Identifier id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); return id == null ? "unknown" : id.toString(); } diff --git a/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/ExplicitFluidReference.java b/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/ExplicitFluidReference.java index 716b7ffa..232aae7e 100644 --- a/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/ExplicitFluidReference.java +++ b/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/ExplicitFluidReference.java @@ -1,7 +1,7 @@ package zone.moddev.mc.basepower.registry.still.recipe; import net.minecraft.world.level.material.Fluid; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; /** @@ -44,7 +44,7 @@ public boolean matches(Fluid fluid) { */ @Override public boolean matches(String fluidName) { - ResourceLocation id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(reference); + Identifier id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(reference); return id != null && (id.toString().equals(fluidName) || id.getPath().equals(fluidName)); } @@ -65,7 +65,7 @@ public Fluid getFluid() { */ @Override public int hashCode() { - ResourceLocation id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(reference); + Identifier id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(reference); return id == null ? System.identityHashCode(reference) : id.hashCode(); } } diff --git a/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/RegistryNameFluidReference.java b/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/RegistryNameFluidReference.java index e539c931..10439548 100644 --- a/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/RegistryNameFluidReference.java +++ b/src/main/java/zone/moddev/mc/basepower/registry/still/recipe/RegistryNameFluidReference.java @@ -1,7 +1,7 @@ package zone.moddev.mc.basepower.registry.still.recipe; import net.minecraft.world.level.material.Fluid; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; import zone.moddev.mc.basepower.init.Fluids; @@ -28,7 +28,7 @@ public RegistryNameFluidReference(String registryName) { */ @Override public boolean matches(Fluid fluid) { - ResourceLocation id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); + Identifier id = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); return id != null && (reference.equals(id.toString()) || reference.equals(id.getPath())); } diff --git a/src/main/java/zone/moddev/mc/basepower/util/FluidContainerHelper.java b/src/main/java/zone/moddev/mc/basepower/util/FluidContainerHelper.java index 1290b705..25e79177 100644 --- a/src/main/java/zone/moddev/mc/basepower/util/FluidContainerHelper.java +++ b/src/main/java/zone/moddev/mc/basepower/util/FluidContainerHelper.java @@ -25,7 +25,7 @@ private FluidContainerHelper() {} public static boolean interact(Player player, InteractionHand hand, IAdvantageFluidHandler handler, Direction face) { - if (player.level().isClientSide) return false; + if (player.level().isClientSide()) return false; ItemStack held = player.getItemInHand(hand); if (held.isEmpty()) return false; IFluidHandlerItem container = itemHandler(held.copyWithCount(1)).orElse(null); diff --git a/src/main/java/zone/moddev/mc/basepower/util/FluidIdHelper.java b/src/main/java/zone/moddev/mc/basepower/util/FluidIdHelper.java index 45f122c2..159d08e6 100644 --- a/src/main/java/zone/moddev/mc/basepower/util/FluidIdHelper.java +++ b/src/main/java/zone/moddev/mc/basepower/util/FluidIdHelper.java @@ -1,7 +1,7 @@ package zone.moddev.mc.basepower.util; import net.minecraft.world.level.material.Fluid; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; public final class FluidIdHelper { @@ -12,14 +12,14 @@ private FluidIdHelper() { public static int getFluidId(Fluid fluid) { if (fluid == null) return 0; - ResourceLocation name = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); + Identifier name = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); return name == null ? 0 : name.toString().hashCode(); } public static Fluid getFluid(int id) { if (id == 0) return null; for (Fluid fluid : net.minecraftforge.registries.ForgeRegistries.FLUIDS) { - ResourceLocation name = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); + Identifier name = net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid); if (name != null && name.toString().hashCode() == id) return fluid; } return null; diff --git a/src/main/java/zone/moddev/mc/basepower/util/InventoryWrapper.java b/src/main/java/zone/moddev/mc/basepower/util/InventoryWrapper.java index 8502239b..4f7ba56e 100644 --- a/src/main/java/zone/moddev/mc/basepower/util/InventoryWrapper.java +++ b/src/main/java/zone/moddev/mc/basepower/util/InventoryWrapper.java @@ -48,8 +48,8 @@ public static WorldlyContainer wrap(Container inventory) { @Override public boolean stillValid(Player player) { return slots.length != 0 && inventory.stillValid(player); } - @Override public void startOpen(Player player) { inventory.startOpen(player); } - @Override public void stopOpen(Player player) { inventory.stopOpen(player); } + @Override public void startOpen(net.minecraft.world.entity.ContainerUser user) { inventory.startOpen(user); } + @Override public void stopOpen(net.minecraft.world.entity.ContainerUser user) { inventory.stopOpen(user); } @Override public void setChanged() { inventory.setChanged(); } @Override public int[] getSlotsForFace(Direction face) { return isLocked() ? NO_SLOTS : slots; } @Override public boolean canPlaceItemThroughFace(int slot, ItemStack stack, Direction face) { diff --git a/src/main/java/zone/moddev/mc/basepower/util/ItemData.java b/src/main/java/zone/moddev/mc/basepower/util/ItemData.java index 02341105..ec2c49fe 100644 --- a/src/main/java/zone/moddev/mc/basepower/util/ItemData.java +++ b/src/main/java/zone/moddev/mc/basepower/util/ItemData.java @@ -9,7 +9,7 @@ import net.minecraft.nbt.NbtOps; import net.minecraft.nbt.Tag; import net.minecraft.resources.ResourceKey; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.util.datafix.DataFixers; import net.minecraft.util.datafix.fixes.References; import net.minecraft.world.item.Item; @@ -27,27 +27,33 @@ public static CompoundTag customData(ItemStack stack) { } public static ItemStack readStack(HolderLookup.Provider registries, CompoundTag saved) { - if (saved.contains("Count", 99)) { + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(saved, "Count", 99)) { // Vanilla does not descend into arbitrary mod block-entity inventories. saved = withLegacyWear(saved, registries.lookupOrThrow(Registries.ITEM)); saved = (CompoundTag) DataFixers.getDataFixer().update(References.ITEM_STACK, new Dynamic<>(NbtOps.INSTANCE, saved), LAST_PORT_DATA_VERSION, - net.minecraft.SharedConstants.getCurrentVersion().getDataVersion().getVersion()).getValue(); + net.minecraft.SharedConstants.getCurrentVersion().dataVersion().version()).getValue(); } - return ItemStack.parseOptional(registries, saved); + return ItemStack.CODEC.parse(registries.createSerializationContext(NbtOps.INSTANCE), saved) + .result().orElse(ItemStack.EMPTY); + } + + public static CompoundTag writeStack(ItemStack stack, HolderLookup.Provider registries) { + return (CompoundTag) ItemStack.CODEC.encodeStart(registries.createSerializationContext(NbtOps.INSTANCE), stack) + .getOrThrow(); } public static CompoundTag withLegacyWear(CompoundTag saved, HolderGetter items) { - if (!saved.contains("Damage", Tag.TAG_ANY_NUMERIC) || saved.getInt("Damage") <= 0 - || saved.getCompound("tag").contains("Damage", Tag.TAG_ANY_NUMERIC)) return saved; - ResourceLocation id = ResourceLocation.tryParse(saved.getString("id")); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(saved, "Damage", 99) || saved.getIntOr("Damage", 0) <= 0 + || NbtCompat.containsTag(saved.getCompoundOrEmpty("tag"), "Damage", 99)) return saved; + Identifier id = Identifier.tryParse(saved.getStringOr("id", "")); if (id == null || id.getNamespace().equals("minecraft") || items.get(ResourceKey.create(Registries.ITEM, id)) .filter(holder -> holder.value().components().has(DataComponents.MAX_DAMAGE)).isEmpty()) return saved; // The native component fixer reads wear from tag.Damage; it cannot infer // pre-flattening metadata semantics for a modded item. CompoundTag copy = saved.copy(); - CompoundTag tag = copy.getCompound("tag"); - tag.putInt("Damage", copy.getInt("Damage")); + CompoundTag tag = copy.getCompoundOrEmpty("tag"); + tag.putInt("Damage", copy.getIntOr("Damage", 0)); copy.put("tag", tag); return copy; } diff --git a/src/main/java/zone/moddev/mc/basepower/util/LegacyNbtFields.java b/src/main/java/zone/moddev/mc/basepower/util/LegacyNbtFields.java new file mode 100644 index 00000000..b36f596c --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/util/LegacyNbtFields.java @@ -0,0 +1,29 @@ +package zone.moddev.mc.basepower.util; + +import com.mojang.serialization.MapCodec; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.storage.TagValueOutput; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** Keeps the legacy API's flat NBT fields at the native value-I/O boundary. */ +public final class LegacyNbtFields { + private static final MapCodec FIELDS = MapCodec.assumeMapUnsafe(CompoundTag.CODEC); + + private LegacyNbtFields() { } + + @SuppressWarnings("deprecation") + public static CompoundTag read(ValueInput input) { + return input.read(FIELDS).orElseGet(CompoundTag::new); + } + + @SuppressWarnings("deprecation") + public static void write(ValueOutput output, CompoundTag fields) { + output.store(FIELDS, fields); + } + + public static HolderLookup.Provider registries(ValueOutput output, HolderLookup.Provider fallback) { + return output instanceof TagValueOutput tag && tag.lookup != null ? tag.lookup : fallback; + } +} diff --git a/src/main/java/zone/moddev/mc/basepower/util/NbtCompat.java b/src/main/java/zone/moddev/mc/basepower/util/NbtCompat.java new file mode 100644 index 00000000..f33075fe --- /dev/null +++ b/src/main/java/zone/moddev/mc/basepower/util/NbtCompat.java @@ -0,0 +1,15 @@ +package zone.moddev.mc.basepower.util; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NumericTag; +import net.minecraft.nbt.Tag; + +/** Preserves legacy numeric-type checks when reading older saved machine data. */ +public final class NbtCompat { + private NbtCompat() {} + + public static boolean containsTag(CompoundTag compound, String key, int type) { + Tag value = compound.get(key); + return value != null && (type == 99 ? value instanceof NumericTag : value.getId() == type); + } +} diff --git a/src/main/resources/META-INF/coremods.json b/src/main/resources/META-INF/coremods.json deleted file mode 100644 index 92fa3573..00000000 --- a/src/main/resources/META-INF/coremods.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "poweradvantage_118_migration": "coremods/poweradvantage_118_migration.js" -} diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index bf430c4a..f16efe70 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -1,6 +1,7 @@ modLoader="javafml" loaderVersion="${loader_version_range}" license="LGPL-3.0-only" +showAsResourcePack=false issueTrackerURL="https://github.com/MinecraftModDevelopmentMods/basePower/issues" [[mods]] @@ -13,15 +14,15 @@ description='''API and shared machines for pressure, electricity, quantum power, [[dependencies.basepower]] modId="basemetals" -mandatory=false -versionRange="[3.1.2.120061,4.0.0)" +mandatory=true +versionRange="[3.1.2.121111,4.0.0)" ordering="AFTER" side="BOTH" [[dependencies.basepower]] modId="orespawn" mandatory=true -versionRange="[4.1.0.120061,5.0.0)" +versionRange="[4.1.0.121111,5.0.0)" ordering="AFTER" side="BOTH" diff --git a/src/main/resources/assets/basepower/items/bioplastic_ingot.json b/src/main/resources/assets/basepower/items/bioplastic_ingot.json new file mode 100644 index 00000000..9551951d --- /dev/null +++ b/src/main/resources/assets/basepower/items/bioplastic_ingot.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/bioplastic_ingot" + } +} diff --git a/src/main/resources/assets/basepower/items/bucket_crude_oil.json b/src/main/resources/assets/basepower/items/bucket_crude_oil.json new file mode 100644 index 00000000..5b43591a --- /dev/null +++ b/src/main/resources/assets/basepower/items/bucket_crude_oil.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/bucket_crude_oil" + } +} diff --git a/src/main/resources/assets/basepower/items/bucket_refined_oil.json b/src/main/resources/assets/basepower/items/bucket_refined_oil.json new file mode 100644 index 00000000..3f789f48 --- /dev/null +++ b/src/main/resources/assets/basepower/items/bucket_refined_oil.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/bucket_refined_oil" + } +} diff --git a/src/main/resources/assets/basepower/items/converter_rf_electricity.json b/src/main/resources/assets/basepower/items/converter_rf_electricity.json new file mode 100644 index 00000000..1b17bedb --- /dev/null +++ b/src/main/resources/assets/basepower/items/converter_rf_electricity.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/converter_rf_electricity" + } +} diff --git a/src/main/resources/assets/basepower/items/converter_rf_quantum.json b/src/main/resources/assets/basepower/items/converter_rf_quantum.json new file mode 100644 index 00000000..66d353b8 --- /dev/null +++ b/src/main/resources/assets/basepower/items/converter_rf_quantum.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/converter_rf_quantum" + } +} diff --git a/src/main/resources/assets/basepower/items/converter_rf_steam.json b/src/main/resources/assets/basepower/items/converter_rf_steam.json new file mode 100644 index 00000000..93308c63 --- /dev/null +++ b/src/main/resources/assets/basepower/items/converter_rf_steam.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/converter_rf_steam" + } +} diff --git a/src/main/resources/assets/basepower/items/converter_tr_electricity.json b/src/main/resources/assets/basepower/items/converter_tr_electricity.json new file mode 100644 index 00000000..9aa54728 --- /dev/null +++ b/src/main/resources/assets/basepower/items/converter_tr_electricity.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/converter_tr_electricity" + } +} diff --git a/src/main/resources/assets/basepower/items/crude_oil.json b/src/main/resources/assets/basepower/items/crude_oil.json new file mode 100644 index 00000000..df645e71 --- /dev/null +++ b/src/main/resources/assets/basepower/items/crude_oil.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/crude_oil" + } +} diff --git a/src/main/resources/assets/basepower/items/crude_oil_bucket.json b/src/main/resources/assets/basepower/items/crude_oil_bucket.json new file mode 100644 index 00000000..c198de4c --- /dev/null +++ b/src/main/resources/assets/basepower/items/crude_oil_bucket.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/crude_oil_bucket" + } +} diff --git a/src/main/resources/assets/basepower/items/fluid_discharge.json b/src/main/resources/assets/basepower/items/fluid_discharge.json new file mode 100644 index 00000000..42453623 --- /dev/null +++ b/src/main/resources/assets/basepower/items/fluid_discharge.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/fluid_discharge" + } +} diff --git a/src/main/resources/assets/basepower/items/fluid_drain.json b/src/main/resources/assets/basepower/items/fluid_drain.json new file mode 100644 index 00000000..1f566464 --- /dev/null +++ b/src/main/resources/assets/basepower/items/fluid_drain.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/fluid_drain" + } +} diff --git a/src/main/resources/assets/basepower/items/fluid_metal_tank.json b/src/main/resources/assets/basepower/items/fluid_metal_tank.json new file mode 100644 index 00000000..48cbd06b --- /dev/null +++ b/src/main/resources/assets/basepower/items/fluid_metal_tank.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/fluid_metal_tank" + } +} diff --git a/src/main/resources/assets/basepower/items/fluid_pipe.json b/src/main/resources/assets/basepower/items/fluid_pipe.json new file mode 100644 index 00000000..a4bc575e --- /dev/null +++ b/src/main/resources/assets/basepower/items/fluid_pipe.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/fluid_pipe" + } +} diff --git a/src/main/resources/assets/basepower/items/fluid_pipe_terminal.json b/src/main/resources/assets/basepower/items/fluid_pipe_terminal.json new file mode 100644 index 00000000..46bef6e3 --- /dev/null +++ b/src/main/resources/assets/basepower/items/fluid_pipe_terminal.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/fluid_pipe_terminal" + } +} diff --git a/src/main/resources/assets/basepower/items/fluid_storage_tank.json b/src/main/resources/assets/basepower/items/fluid_storage_tank.json new file mode 100644 index 00000000..fb6dfad3 --- /dev/null +++ b/src/main/resources/assets/basepower/items/fluid_storage_tank.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/fluid_storage_tank" + } +} diff --git a/src/main/resources/assets/basepower/items/fluid_switch.json b/src/main/resources/assets/basepower/items/fluid_switch.json new file mode 100644 index 00000000..2282eef8 --- /dev/null +++ b/src/main/resources/assets/basepower/items/fluid_switch.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/fluid_switch" + } +} diff --git a/src/main/resources/assets/basepower/items/infinite_electricity.json b/src/main/resources/assets/basepower/items/infinite_electricity.json new file mode 100644 index 00000000..e5d937c9 --- /dev/null +++ b/src/main/resources/assets/basepower/items/infinite_electricity.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/infinite_electricity" + } +} diff --git a/src/main/resources/assets/basepower/items/infinite_quantum.json b/src/main/resources/assets/basepower/items/infinite_quantum.json new file mode 100644 index 00000000..fc91e6bc --- /dev/null +++ b/src/main/resources/assets/basepower/items/infinite_quantum.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/infinite_quantum" + } +} diff --git a/src/main/resources/assets/basepower/items/infinite_steam.json b/src/main/resources/assets/basepower/items/infinite_steam.json new file mode 100644 index 00000000..1fcf65e1 --- /dev/null +++ b/src/main/resources/assets/basepower/items/infinite_steam.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/infinite_steam" + } +} diff --git a/src/main/resources/assets/basepower/items/item_conveyor.json b/src/main/resources/assets/basepower/items/item_conveyor.json new file mode 100644 index 00000000..525380f2 --- /dev/null +++ b/src/main/resources/assets/basepower/items/item_conveyor.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/item_conveyor" + } +} diff --git a/src/main/resources/assets/basepower/items/item_filter_block.json b/src/main/resources/assets/basepower/items/item_filter_block.json new file mode 100644 index 00000000..1e24639d --- /dev/null +++ b/src/main/resources/assets/basepower/items/item_filter_block.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/item_filter_block" + } +} diff --git a/src/main/resources/assets/basepower/items/item_filter_food.json b/src/main/resources/assets/basepower/items/item_filter_food.json new file mode 100644 index 00000000..28a16440 --- /dev/null +++ b/src/main/resources/assets/basepower/items/item_filter_food.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/item_filter_food" + } +} diff --git a/src/main/resources/assets/basepower/items/item_filter_fuel.json b/src/main/resources/assets/basepower/items/item_filter_fuel.json new file mode 100644 index 00000000..136847ca --- /dev/null +++ b/src/main/resources/assets/basepower/items/item_filter_fuel.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/item_filter_fuel" + } +} diff --git a/src/main/resources/assets/basepower/items/item_filter_inventory.json b/src/main/resources/assets/basepower/items/item_filter_inventory.json new file mode 100644 index 00000000..3c4fead0 --- /dev/null +++ b/src/main/resources/assets/basepower/items/item_filter_inventory.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/item_filter_inventory" + } +} diff --git a/src/main/resources/assets/basepower/items/item_filter_ore.json b/src/main/resources/assets/basepower/items/item_filter_ore.json new file mode 100644 index 00000000..2d683ee5 --- /dev/null +++ b/src/main/resources/assets/basepower/items/item_filter_ore.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/item_filter_ore" + } +} diff --git a/src/main/resources/assets/basepower/items/item_filter_overflow.json b/src/main/resources/assets/basepower/items/item_filter_overflow.json new file mode 100644 index 00000000..83f272ff --- /dev/null +++ b/src/main/resources/assets/basepower/items/item_filter_overflow.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/item_filter_overflow" + } +} diff --git a/src/main/resources/assets/basepower/items/item_filter_plant.json b/src/main/resources/assets/basepower/items/item_filter_plant.json new file mode 100644 index 00000000..23caf0bb --- /dev/null +++ b/src/main/resources/assets/basepower/items/item_filter_plant.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/item_filter_plant" + } +} diff --git a/src/main/resources/assets/basepower/items/item_filter_smelt.json b/src/main/resources/assets/basepower/items/item_filter_smelt.json new file mode 100644 index 00000000..c5f041cb --- /dev/null +++ b/src/main/resources/assets/basepower/items/item_filter_smelt.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/item_filter_smelt" + } +} diff --git a/src/main/resources/assets/basepower/items/refined_oil.json b/src/main/resources/assets/basepower/items/refined_oil.json new file mode 100644 index 00000000..cac3d1e1 --- /dev/null +++ b/src/main/resources/assets/basepower/items/refined_oil.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/refined_oil" + } +} diff --git a/src/main/resources/assets/basepower/items/refined_oil_bucket.json b/src/main/resources/assets/basepower/items/refined_oil_bucket.json new file mode 100644 index 00000000..f89a2679 --- /dev/null +++ b/src/main/resources/assets/basepower/items/refined_oil_bucket.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/refined_oil_bucket" + } +} diff --git a/src/main/resources/assets/basepower/items/rotator_tool.json b/src/main/resources/assets/basepower/items/rotator_tool.json new file mode 100644 index 00000000..748402a5 --- /dev/null +++ b/src/main/resources/assets/basepower/items/rotator_tool.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/rotator_tool" + } +} diff --git a/src/main/resources/assets/basepower/items/sprocket.json b/src/main/resources/assets/basepower/items/sprocket.json new file mode 100644 index 00000000..068e36c6 --- /dev/null +++ b/src/main/resources/assets/basepower/items/sprocket.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/sprocket" + } +} diff --git a/src/main/resources/assets/basepower/items/starch.json b/src/main/resources/assets/basepower/items/starch.json new file mode 100644 index 00000000..0f26a662 --- /dev/null +++ b/src/main/resources/assets/basepower/items/starch.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/starch" + } +} diff --git a/src/main/resources/assets/basepower/items/steel_frame.json b/src/main/resources/assets/basepower/items/steel_frame.json new file mode 100644 index 00000000..e861a17a --- /dev/null +++ b/src/main/resources/assets/basepower/items/steel_frame.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/steel_frame" + } +} diff --git a/src/main/resources/assets/basepower/items/still.json b/src/main/resources/assets/basepower/items/still.json new file mode 100644 index 00000000..e4a7bfba --- /dev/null +++ b/src/main/resources/assets/basepower/items/still.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "basepower:item/still" + } +} diff --git a/src/main/resources/assets/minecraft/atlases/items.json b/src/main/resources/assets/minecraft/atlases/items.json new file mode 100644 index 00000000..c2643eee --- /dev/null +++ b/src/main/resources/assets/minecraft/atlases/items.json @@ -0,0 +1,5 @@ +{ + "sources": [ + { "type": "directory", "source": "items", "prefix": "items/" } + ] +} diff --git a/src/main/resources/basepower.mixins.json b/src/main/resources/basepower.mixins.json new file mode 100644 index 00000000..e84dc9e1 --- /dev/null +++ b/src/main/resources/basepower.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8.7", + "package": "zone.moddev.mc.basepower.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "BlockStateDataMixin", + "LevelStorageSourceMixin", + "PlayerDataStorageMixin", + "SimpleRegionStorageMixin", + "WorldGenRegionMixin" + ], + "injectors": { "defaultRequire": 1 } +} diff --git a/src/main/resources/coremods/poweradvantage_118_migration.js b/src/main/resources/coremods/poweradvantage_118_migration.js deleted file mode 100644 index e8261837..00000000 --- a/src/main/resources/coremods/poweradvantage_118_migration.js +++ /dev/null @@ -1,230 +0,0 @@ -var Opcodes = Java.type('org.objectweb.asm.Opcodes'); -var InsnNode = Java.type('org.objectweb.asm.tree.InsnNode'); -var InsnList = Java.type('org.objectweb.asm.tree.InsnList'); -var JumpInsnNode = Java.type('org.objectweb.asm.tree.JumpInsnNode'); -var LabelNode = Java.type('org.objectweb.asm.tree.LabelNode'); -var MethodInsnNode = Java.type('org.objectweb.asm.tree.MethodInsnNode'); -var VarInsnNode = Java.type('org.objectweb.asm.tree.VarInsnNode'); - -// Only pre-datafix migration needs bytecode integration. -var hook = 'zone/moddev/mc/basepower/migration/LegacyWorldDataHook'; -var metadata = 'zone/moddev/mc/basepower/migration/LegacyWorldMetadata'; -var dataFixTypes = 'net/minecraft/util/datafix/DataFixTypes'; - -function previousInstruction(instruction) { - var previous = instruction.getPrevious(); - while (previous !== null && previous.getOpcode() < 0) previous = previous.getPrevious(); - return previous; -} - -function isEmbeddedPlayerCall(call) { - // The Forge 50 reader pushes PLAYER, then the fixer, value and version. - // Inspect the receiver for this invocation, not an unrelated earlier GETSTATIC. - var argument = call; - var loads = [Opcodes.ILOAD, Opcodes.ALOAD, Opcodes.ALOAD]; - for (var index = 0; index < loads.length; ++index) { - argument = previousInstruction(argument); - if (argument === null || argument.getOpcode() !== loads[index]) return false; - } - var receiver = previousInstruction(argument); - return receiver !== null && receiver.getOpcode() === Opcodes.GETSTATIC && - receiver.owner === dataFixTypes && receiver.name === 'PLAYER' && - receiver.desc === 'L' + dataFixTypes + ';'; -} - -function patchPlayerDataFix(classNode) { - var descriptor = '(Lcom/mojang/datafixers/DataFixer;' + - 'Lnet/minecraft/nbt/CompoundTag;I)Lnet/minecraft/nbt/CompoundTag;'; - var matches = 0; - var matchedMethod = null; - var matchedCall = null; - for (var i = 0; i < classNode.methods.size(); ++i) { - var method = classNode.methods.get(i); - for (var instruction = method.instructions.getFirst(); instruction !== null; - instruction = instruction.getNext()) { - if (instruction.getOpcode() === Opcodes.INVOKEVIRTUAL && - instruction.owner === dataFixTypes && instruction.desc === descriptor) { - ++matches; - matchedMethod = method; - matchedCall = instruction; - } - } - } - if (matches !== 1) { - throw new Error('Power Advantage requires exactly one native player datafix call in ' + classNode.name); - } - matchedMethod.instructions.set(matchedCall, new MethodInsnNode(Opcodes.INVOKESTATIC, - 'zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes', 'updateNativePlayer', - '(Lnet/minecraft/util/datafix/DataFixTypes;' + descriptor.substring(1), false)); - return classNode; -} - -function initializeCoreMod() { - return { - 'poweradvantage_legacy_player_file': { - 'target': { 'type': 'CLASS', 'name': 'net.minecraft.world.level.storage.PlayerDataStorage' }, - 'transformer': patchPlayerDataFix - }, - 'poweradvantage_legacy_world_info': { - 'target': { 'type': 'CLASS', 'name': 'net.minecraft.world.level.storage.LevelStorageSource' }, - 'transformer': function(classNode) { - var worldReaders = []; - var rawReturns = []; - var lightweightReturns = []; - var embeddedSites = []; - var dynamic = 'Lcom/mojang/serialization/Dynamic;'; - var nbt = 'Lnet/minecraft/nbt/CompoundTag;'; - var embedded = '(Lcom/mojang/datafixers/DataFixer;' + dynamic + 'I)' + dynamic; - for (var i = 0; i < classNode.methods.size(); ++i) { - var method = classNode.methods.get(i); - if (method.name === 'readLevelDataTagFixed' && - method.desc === '(Ljava/nio/file/Path;Lcom/mojang/datafixers/DataFixer;)' + dynamic) { - worldReaders.push(method); - } - var calls = []; - var playerCall = null; - for (var insn = method.instructions.getFirst(); insn !== null; insn = insn.getNext()) { - if (insn.getOpcode() === Opcodes.ARETURN) { - if (method.name === 'readLevelDataTagRaw' && method.desc === '(Ljava/nio/file/Path;)' + nbt) { - rawReturns.push({ method: method, instruction: insn }); - } else if (method.name === 'readLightweightData' && - method.desc === '(Ljava/nio/file/Path;)Lnet/minecraft/nbt/Tag;') { - lightweightReturns.push({ method: method, instruction: insn }); - } - } - if (insn.getOpcode() === Opcodes.INVOKEVIRTUAL && insn.owner === dataFixTypes && - insn.desc === embedded) { - calls.push(insn); - if (isEmbeddedPlayerCall(insn)) playerCall = insn; - } - } - if (playerCall !== null) { - if (calls.length !== 1) throw new Error('Power Advantage found an ambiguous embedded player reader'); - embeddedSites.push({ method: method, instruction: playerCall }); - } - } - if (worldReaders.length !== 1 || rawReturns.length !== 1 || - lightweightReturns.length !== 1 || embeddedSites.length !== 1) { - throw new Error('Power Advantage could not attach its Forge 50 level and embedded player readers'); - } - var prefix = new InsnList(); - prefix.add(new VarInsnNode(Opcodes.ALOAD, 0)); - prefix.add(new MethodInsnNode(Opcodes.INVOKEINTERFACE, 'java/nio/file/Path', 'toFile', '()Ljava/io/File;', true)); - prefix.add(new MethodInsnNode(Opcodes.INVOKESTATIC, hook, 'prepareLegacyWorld', '(Ljava/io/File;)V', false)); - worldReaders[0].instructions.insert(prefix); - rawReturns[0].method.instructions.insertBefore(rawReturns[0].instruction, new MethodInsnNode( - Opcodes.INVOKESTATIC, metadata, 'remapRaw', '(' + nbt + ')' + nbt, false)); - lightweightReturns[0].method.instructions.insertBefore(lightweightReturns[0].instruction, new MethodInsnNode( - Opcodes.INVOKESTATIC, metadata, 'remapLightweight', '(Lnet/minecraft/nbt/Tag;)Lnet/minecraft/nbt/Tag;', false)); - embeddedSites[0].method.instructions.set(embeddedSites[0].instruction, new MethodInsnNode( - Opcodes.INVOKESTATIC, 'zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes', - 'updateNativeEmbeddedPlayer', '(L' + dataFixTypes + ';' + embedded.substring(1), false)); - return classNode; - } - }, - 'poweradvantage_legacy_chunk_status': { - 'target': { 'type': 'CLASS', 'name': 'net.minecraft.world.level.chunk.storage.ChunkStorage' }, - 'transformer': function(classNode) { - var descriptor = '(Lnet/minecraft/resources/ResourceKey;' + - 'Ljava/util/function/Supplier;Lnet/minecraft/nbt/CompoundTag;Ljava/util/Optional;)' + - 'Lnet/minecraft/nbt/CompoundTag;'; - var patchedRead = false; - var patchedReturn = false; - var patchedOwnership = false; - var patchedEntities = false; - for (var i = 0; i < classNode.methods.size(); ++i) { - var method = classNode.methods.get(i); - if (method.desc === '(Lnet/minecraft/world/level/ChunkPos;)Ljava/util/concurrent/CompletableFuture;') { - for (var read = method.instructions.getFirst(); read !== null; read = read.getNext()) { - if (read.getOpcode() === Opcodes.ARETURN) { - method.instructions.insertBefore(read, new MethodInsnNode(Opcodes.INVOKESTATIC, - hook, 'copyChunkReadForMigration', - '(Ljava/util/concurrent/CompletableFuture;)Ljava/util/concurrent/CompletableFuture;', false)); - patchedOwnership = true; - } - } - } - if (method.desc !== descriptor) continue; - // Other coremods prepend their preparation. Stay after all those entry - // hooks so modded item durability is rescued before entity flattening. - for (var first = method.instructions.getFirst(); first !== null; first = first.getNext()) { - if (first.getOpcode() === Opcodes.INVOKESTATIC && first.owner === classNode.name && - first.desc === '(Lnet/minecraft/nbt/CompoundTag;)I') { - var entities = new InsnList(); - entities.add(new VarInsnNode(Opcodes.ALOAD, 3)); - entities.add(new MethodInsnNode(Opcodes.INVOKESTATIC, hook, - 'prepareLegacyEntities', '(Lnet/minecraft/nbt/CompoundTag;)V', false)); - method.instructions.insertBefore(first, entities); - patchedEntities = true; - break; - } - } - var prepare = new InsnList(); - prepare.add(new VarInsnNode(Opcodes.ALOAD, 3)); - prepare.add(new MethodInsnNode(Opcodes.INVOKESTATIC, hook, - 'prepareLegacyChunk', '(Lnet/minecraft/nbt/CompoundTag;)V', false)); - method.instructions.insert(prepare); - patchedRead = true; - for (var instruction = method.instructions.getFirst(); instruction !== null; - instruction = instruction.getNext()) { - if (instruction.getOpcode() === Opcodes.ARETURN) { - method.instructions.insertBefore(instruction, new MethodInsnNode(Opcodes.INVOKESTATIC, - hook, 'finalizeLegacyChunk', - '(Lnet/minecraft/nbt/CompoundTag;)Lnet/minecraft/nbt/CompoundTag;', false)); - patchedReturn = true; - } - } - } - if (!patchedRead || !patchedReturn || !patchedOwnership || !patchedEntities) { - throw new Error('Power Advantage could not attach its legacy chunk converter'); - } - return classNode; - } - }, - 'poweradvantage_separate_entity_storage': { - 'target': { 'type': 'CLASS', 'name': 'net.minecraft.world.level.chunk.storage.EntityStorage' }, - 'transformer': function(classNode) { - var matches = 0; - var storage = 'net/minecraft/world/level/chunk/storage/SimpleRegionStorage'; - var descriptor = '(Lnet/minecraft/nbt/CompoundTag;I)Lnet/minecraft/nbt/CompoundTag;'; - for (var i = 0; i < classNode.methods.size(); ++i) { - var method = classNode.methods.get(i); - for (var call = method.instructions.getFirst(); call !== null; call = call.getNext()) { - if (call.getOpcode() !== Opcodes.INVOKEVIRTUAL || call.owner !== storage || - call.name !== 'upgradeChunkTag' || call.desc !== descriptor) continue; - method.instructions.set(call, new MethodInsnNode(Opcodes.INVOKESTATIC, hook, - 'upgradeEntityChunk', '(L' + storage + ';' + descriptor.substring(1), false)); - ++matches; - } - } - if (matches !== 1) throw new Error('Power Advantage requires exactly one native entity-chunk converter'); - return classNode; - } - }, - 'poweradvantage_legacy_worldgen_guard': { - 'target': { 'type': 'CLASS', 'name': 'net.minecraft.server.level.WorldGenRegion' }, - 'transformer': function(classNode) { - var descriptor = '(Lnet/minecraft/core/BlockPos;)Z'; - var patched = false; - for (var i = 0; i < classNode.methods.size(); ++i) { - var method = classNode.methods.get(i); - if (method.desc !== descriptor || method.name !== 'ensureCanWrite') continue; - var allowed = new LabelNode(); - var prefix = new InsnList(); - prefix.add(new VarInsnNode(Opcodes.ALOAD, 0)); - prefix.add(new VarInsnNode(Opcodes.ALOAD, 1)); - prefix.add(new MethodInsnNode(Opcodes.INVOKESTATIC, hook, - 'shouldBlockWorldgenWrite', '(Lnet/minecraft/server/level/WorldGenRegion;Lnet/minecraft/core/BlockPos;)Z', false)); - prefix.add(new JumpInsnNode(Opcodes.IFEQ, allowed)); - prefix.add(new InsnNode(Opcodes.ICONST_0)); - prefix.add(new InsnNode(Opcodes.IRETURN)); - prefix.add(allowed); - method.instructions.insert(prefix); - patched = true; - } - if (!patched) throw new Error('Power Advantage could not protect existing legacy terrain'); - return classNode; - } - } - }; -} diff --git a/src/main/resources/data/basepower/recipe/bioplastic_from_starch.json b/src/main/resources/data/basepower/recipe/bioplastic_from_starch.json index 8eb5c06f..04541096 100644 --- a/src/main/resources/data/basepower/recipe/bioplastic_from_starch.json +++ b/src/main/resources/data/basepower/recipe/bioplastic_from_starch.json @@ -1,8 +1,6 @@ { "type": "minecraft:smelting", - "ingredient": { - "item": "basepower:starch" - }, + "ingredient": "basepower:starch", "result": { "id": "basepower:bioplastic_ingot" }, diff --git a/src/main/resources/data/basepower/recipe/converter_rf_electricity.json b/src/main/resources/data/basepower/recipe/converter_rf_electricity.json index f6842b6b..fbb8e9a2 100644 --- a/src/main/resources/data/basepower/recipe/converter_rf_electricity.json +++ b/src/main/resources/data/basepower/recipe/converter_rf_electricity.json @@ -4,15 +4,9 @@ "xyz" ], "key": { - "x": { - "tag": "forge:power_supply_units" - }, - "y": { - "tag": "forge:frames/steel" - }, - "z": { - "item": "minecraft:redstone_block" - } + "x": "#forge:power_supply_units", + "y": "#forge:frames/steel", + "z": "minecraft:redstone_block" }, "result": { "id": "basepower:converter_rf_electricity" diff --git a/src/main/resources/data/basepower/recipe/converter_rf_quantum.json b/src/main/resources/data/basepower/recipe/converter_rf_quantum.json index 3d5d6d9b..14c65889 100644 --- a/src/main/resources/data/basepower/recipe/converter_rf_quantum.json +++ b/src/main/resources/data/basepower/recipe/converter_rf_quantum.json @@ -4,15 +4,9 @@ "xyz" ], "key": { - "x": { - "item": "minecraft:ender_pearl" - }, - "y": { - "tag": "forge:frames/steel" - }, - "z": { - "item": "minecraft:redstone_block" - } + "x": "minecraft:ender_pearl", + "y": "#forge:frames/steel", + "z": "minecraft:redstone_block" }, "result": { "id": "basepower:converter_rf_quantum" diff --git a/src/main/resources/data/basepower/recipe/converter_rf_steam.json b/src/main/resources/data/basepower/recipe/converter_rf_steam.json index 46b3a00b..3452bd5a 100644 --- a/src/main/resources/data/basepower/recipe/converter_rf_steam.json +++ b/src/main/resources/data/basepower/recipe/converter_rf_steam.json @@ -4,15 +4,9 @@ "xyz" ], "key": { - "x": { - "tag": "forge:governors" - }, - "y": { - "tag": "forge:frames/steel" - }, - "z": { - "item": "minecraft:redstone_block" - } + "x": "#forge:governors", + "y": "#forge:frames/steel", + "z": "minecraft:redstone_block" }, "result": { "id": "basepower:converter_rf_steam" diff --git a/src/main/resources/data/basepower/recipe/converter_tr_electricity_iron.json b/src/main/resources/data/basepower/recipe/converter_tr_electricity_iron.json index 3d674798..ffd68906 100644 --- a/src/main/resources/data/basepower/recipe/converter_tr_electricity_iron.json +++ b/src/main/resources/data/basepower/recipe/converter_tr_electricity_iron.json @@ -6,12 +6,8 @@ "XXX" ], "key": { - "X": { - "tag": "forge:ingots/iron" - }, - "Z": { - "tag": "forge:power_supply_units" - } + "X": "#forge:ingots/iron", + "Z": "#forge:power_supply_units" }, "result": { "id": "basepower:converter_tr_electricity" diff --git a/src/main/resources/data/basepower/recipe/converter_tr_electricity_refined_iron.json b/src/main/resources/data/basepower/recipe/converter_tr_electricity_refined_iron.json index 2842bff1..cf3171d8 100644 --- a/src/main/resources/data/basepower/recipe/converter_tr_electricity_refined_iron.json +++ b/src/main/resources/data/basepower/recipe/converter_tr_electricity_refined_iron.json @@ -6,12 +6,8 @@ "XXX" ], "key": { - "X": { - "tag": "forge:ingots/refined_iron" - }, - "Z": { - "tag": "forge:power_supply_units" - } + "X": "#forge:ingots/refined_iron", + "Z": "#forge:power_supply_units" }, "result": { "id": "basepower:converter_tr_electricity" diff --git a/src/main/resources/data/basepower/recipe/crushing_fluid_discharge.json b/src/main/resources/data/basepower/recipe/crushing_fluid_discharge.json index d67b2602..b7a6da75 100644 --- a/src/main/resources/data/basepower/recipe/crushing_fluid_discharge.json +++ b/src/main/resources/data/basepower/recipe/crushing_fluid_discharge.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:fluid_discharge" - }, + "ingredient": "basepower:fluid_discharge", "result": { "id": "basepower:fluid_pipe", "count": 2 diff --git a/src/main/resources/data/basepower/recipe/crushing_fluid_drain.json b/src/main/resources/data/basepower/recipe/crushing_fluid_drain.json index 5cb809dd..62ce8c6e 100644 --- a/src/main/resources/data/basepower/recipe/crushing_fluid_drain.json +++ b/src/main/resources/data/basepower/recipe/crushing_fluid_drain.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:fluid_drain" - }, + "ingredient": "basepower:fluid_drain", "result": { "id": "basepower:fluid_pipe", "count": 2 diff --git a/src/main/resources/data/basepower/recipe/crushing_fluid_storage_tank.json b/src/main/resources/data/basepower/recipe/crushing_fluid_storage_tank.json index 300411bb..13754775 100644 --- a/src/main/resources/data/basepower/recipe/crushing_fluid_storage_tank.json +++ b/src/main/resources/data/basepower/recipe/crushing_fluid_storage_tank.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:fluid_storage_tank" - }, + "ingredient": "basepower:fluid_storage_tank", "result": { "id": "basepower:fluid_pipe", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_item_conveyor.json b/src/main/resources/data/basepower/recipe/crushing_item_conveyor.json index 38087ed5..9bb4fad9 100644 --- a/src/main/resources/data/basepower/recipe/crushing_item_conveyor.json +++ b/src/main/resources/data/basepower/recipe/crushing_item_conveyor.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:item_conveyor" - }, + "ingredient": "basepower:item_conveyor", "result": { "id": "basemetals:steel_plate", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_item_filter_block.json b/src/main/resources/data/basepower/recipe/crushing_item_filter_block.json index 9c786824..daf9d83f 100644 --- a/src/main/resources/data/basepower/recipe/crushing_item_filter_block.json +++ b/src/main/resources/data/basepower/recipe/crushing_item_filter_block.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:item_filter_block" - }, + "ingredient": "basepower:item_filter_block", "result": { "id": "basemetals:steel_plate", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_item_filter_food.json b/src/main/resources/data/basepower/recipe/crushing_item_filter_food.json index 5388a4ab..412b2d20 100644 --- a/src/main/resources/data/basepower/recipe/crushing_item_filter_food.json +++ b/src/main/resources/data/basepower/recipe/crushing_item_filter_food.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:item_filter_food" - }, + "ingredient": "basepower:item_filter_food", "result": { "id": "basemetals:steel_plate", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_item_filter_fuel.json b/src/main/resources/data/basepower/recipe/crushing_item_filter_fuel.json index 261ea312..785f2ccb 100644 --- a/src/main/resources/data/basepower/recipe/crushing_item_filter_fuel.json +++ b/src/main/resources/data/basepower/recipe/crushing_item_filter_fuel.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:item_filter_fuel" - }, + "ingredient": "basepower:item_filter_fuel", "result": { "id": "basemetals:steel_plate", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_item_filter_inventory.json b/src/main/resources/data/basepower/recipe/crushing_item_filter_inventory.json index 6e7d8920..f884ddcd 100644 --- a/src/main/resources/data/basepower/recipe/crushing_item_filter_inventory.json +++ b/src/main/resources/data/basepower/recipe/crushing_item_filter_inventory.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:item_filter_inventory" - }, + "ingredient": "basepower:item_filter_inventory", "result": { "id": "basemetals:steel_plate", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_item_filter_ore.json b/src/main/resources/data/basepower/recipe/crushing_item_filter_ore.json index 9f29239f..79343d88 100644 --- a/src/main/resources/data/basepower/recipe/crushing_item_filter_ore.json +++ b/src/main/resources/data/basepower/recipe/crushing_item_filter_ore.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:item_filter_ore" - }, + "ingredient": "basepower:item_filter_ore", "result": { "id": "basemetals:steel_plate", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_item_filter_overflow.json b/src/main/resources/data/basepower/recipe/crushing_item_filter_overflow.json index 5a78fe08..fdb13bfa 100644 --- a/src/main/resources/data/basepower/recipe/crushing_item_filter_overflow.json +++ b/src/main/resources/data/basepower/recipe/crushing_item_filter_overflow.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:item_filter_overflow" - }, + "ingredient": "basepower:item_filter_overflow", "result": { "id": "basemetals:steel_plate", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_item_filter_plant.json b/src/main/resources/data/basepower/recipe/crushing_item_filter_plant.json index 6d1c44af..82974009 100644 --- a/src/main/resources/data/basepower/recipe/crushing_item_filter_plant.json +++ b/src/main/resources/data/basepower/recipe/crushing_item_filter_plant.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:item_filter_plant" - }, + "ingredient": "basepower:item_filter_plant", "result": { "id": "basemetals:steel_plate", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_item_filter_smelt.json b/src/main/resources/data/basepower/recipe/crushing_item_filter_smelt.json index 186812e2..32442a91 100644 --- a/src/main/resources/data/basepower/recipe/crushing_item_filter_smelt.json +++ b/src/main/resources/data/basepower/recipe/crushing_item_filter_smelt.json @@ -1,8 +1,6 @@ { "type": "basemetals:crushing", - "ingredient": { - "item": "basepower:item_filter_smelt" - }, + "ingredient": "basepower:item_filter_smelt", "result": { "id": "basemetals:steel_plate", "count": 1 diff --git a/src/main/resources/data/basepower/recipe/crushing_potato_starch.json b/src/main/resources/data/basepower/recipe/crushing_potato_starch.json index c512154d..26a69054 100644 --- a/src/main/resources/data/basepower/recipe/crushing_potato_starch.json +++ b/src/main/resources/data/basepower/recipe/crushing_potato_starch.json @@ -1,12 +1,8 @@ { "type": "basemetals:crushing", "ingredient": [ - { - "item": "minecraft:potato" - }, - { - "item": "minecraft:poisonous_potato" - } + "minecraft:potato", + "minecraft:poisonous_potato" ], "result": { "id": "basepower:starch", diff --git a/src/main/resources/data/basepower/recipe/fluid_discharge.json b/src/main/resources/data/basepower/recipe/fluid_discharge.json index f11ceb8b..d6d9c22e 100644 --- a/src/main/resources/data/basepower/recipe/fluid_discharge.json +++ b/src/main/resources/data/basepower/recipe/fluid_discharge.json @@ -6,18 +6,10 @@ " x " ], "key": { - "x": { - "tag": "forge:bars" - }, - "w": { - "tag": "forge:plates/steel" - }, - "#": { - "tag": "forge:frames/steel" - }, - "p": { - "tag": "forge:pipes" - } + "x": "#forge:bars", + "w": "#forge:plates/steel", + "#": "#forge:frames/steel", + "p": "#forge:pipes" }, "result": { "id": "basepower:fluid_discharge" diff --git a/src/main/resources/data/basepower/recipe/fluid_drain.json b/src/main/resources/data/basepower/recipe/fluid_drain.json index 1e88fc2f..2b1e1280 100644 --- a/src/main/resources/data/basepower/recipe/fluid_drain.json +++ b/src/main/resources/data/basepower/recipe/fluid_drain.json @@ -6,18 +6,10 @@ "ppp" ], "key": { - "x": { - "tag": "forge:bars" - }, - "w": { - "tag": "forge:plates/steel" - }, - "#": { - "tag": "forge:frames/steel" - }, - "p": { - "tag": "forge:pipes" - } + "x": "#forge:bars", + "w": "#forge:plates/steel", + "#": "#forge:frames/steel", + "p": "#forge:pipes" }, "result": { "id": "basepower:fluid_drain" diff --git a/src/main/resources/data/basepower/recipe/fluid_metal_tank_iron_normal.json b/src/main/resources/data/basepower/recipe/fluid_metal_tank_iron_normal.json index c542ca19..00741d39 100644 --- a/src/main/resources/data/basepower/recipe/fluid_metal_tank_iron_normal.json +++ b/src/main/resources/data/basepower/recipe/fluid_metal_tank_iron_normal.json @@ -6,12 +6,8 @@ "xxx" ], "key": { - "x": { - "tag": "forge:ingots/iron" - }, - "p": { - "tag": "forge:pipes" - } + "x": "#forge:ingots/iron", + "p": "#forge:pipes" }, "result": { "id": "basepower:fluid_metal_tank" diff --git a/src/main/resources/data/basepower/recipe/fluid_metal_tank_steel.json b/src/main/resources/data/basepower/recipe/fluid_metal_tank_steel.json index 3bf46ef3..0ce39cc4 100644 --- a/src/main/resources/data/basepower/recipe/fluid_metal_tank_steel.json +++ b/src/main/resources/data/basepower/recipe/fluid_metal_tank_steel.json @@ -6,12 +6,8 @@ "xxx" ], "key": { - "x": { - "tag": "forge:ingots/steel" - }, - "p": { - "tag": "forge:pipes" - } + "x": "#forge:ingots/steel", + "p": "#forge:pipes" }, "result": { "id": "basepower:fluid_metal_tank" diff --git a/src/main/resources/data/basepower/recipe/fluid_pipe_copper_apocalyptic.json b/src/main/resources/data/basepower/recipe/fluid_pipe_copper_apocalyptic.json index 4c3063d2..9bfb44dc 100644 --- a/src/main/resources/data/basepower/recipe/fluid_pipe_copper_apocalyptic.json +++ b/src/main/resources/data/basepower/recipe/fluid_pipe_copper_apocalyptic.json @@ -6,9 +6,7 @@ "xxx" ], "key": { - "x": { - "tag": "forge:ingots/copper" - } + "x": "#forge:ingots/copper" }, "result": { "id": "basepower:fluid_pipe", diff --git a/src/main/resources/data/basepower/recipe/fluid_pipe_copper_normal.json b/src/main/resources/data/basepower/recipe/fluid_pipe_copper_normal.json index 5a01c2f0..30ef3668 100644 --- a/src/main/resources/data/basepower/recipe/fluid_pipe_copper_normal.json +++ b/src/main/resources/data/basepower/recipe/fluid_pipe_copper_normal.json @@ -6,9 +6,7 @@ "xxx" ], "key": { - "x": { - "tag": "forge:ingots/copper" - } + "x": "#forge:ingots/copper" }, "result": { "id": "basepower:fluid_pipe", diff --git a/src/main/resources/data/basepower/recipe/fluid_pipe_iron_apocalyptic.json b/src/main/resources/data/basepower/recipe/fluid_pipe_iron_apocalyptic.json index 41c45631..06b3c795 100644 --- a/src/main/resources/data/basepower/recipe/fluid_pipe_iron_apocalyptic.json +++ b/src/main/resources/data/basepower/recipe/fluid_pipe_iron_apocalyptic.json @@ -6,9 +6,7 @@ "xxx" ], "key": { - "x": { - "tag": "forge:ingots/iron" - } + "x": "#forge:ingots/iron" }, "result": { "id": "basepower:fluid_pipe", diff --git a/src/main/resources/data/basepower/recipe/fluid_pipe_iron_normal.json b/src/main/resources/data/basepower/recipe/fluid_pipe_iron_normal.json index 74d3213f..26fbd6cd 100644 --- a/src/main/resources/data/basepower/recipe/fluid_pipe_iron_normal.json +++ b/src/main/resources/data/basepower/recipe/fluid_pipe_iron_normal.json @@ -6,9 +6,7 @@ "xxx" ], "key": { - "x": { - "tag": "forge:ingots/iron" - } + "x": "#forge:ingots/iron" }, "result": { "id": "basepower:fluid_pipe", diff --git a/src/main/resources/data/basepower/recipe/fluid_pipe_iron_tech.json b/src/main/resources/data/basepower/recipe/fluid_pipe_iron_tech.json index fa0bc1c4..953279e8 100644 --- a/src/main/resources/data/basepower/recipe/fluid_pipe_iron_tech.json +++ b/src/main/resources/data/basepower/recipe/fluid_pipe_iron_tech.json @@ -6,9 +6,7 @@ "xxx" ], "key": { - "x": { - "tag": "forge:ingots/iron" - } + "x": "#forge:ingots/iron" }, "result": { "id": "basepower:fluid_pipe", diff --git a/src/main/resources/data/basepower/recipe/fluid_pipe_lead_apocalyptic.json b/src/main/resources/data/basepower/recipe/fluid_pipe_lead_apocalyptic.json index 42751b1d..8817403d 100644 --- a/src/main/resources/data/basepower/recipe/fluid_pipe_lead_apocalyptic.json +++ b/src/main/resources/data/basepower/recipe/fluid_pipe_lead_apocalyptic.json @@ -6,9 +6,7 @@ "xxx" ], "key": { - "x": { - "tag": "forge:ingots/lead" - } + "x": "#forge:ingots/lead" }, "result": { "id": "basepower:fluid_pipe", diff --git a/src/main/resources/data/basepower/recipe/fluid_storage_tank.json b/src/main/resources/data/basepower/recipe/fluid_storage_tank.json index f9d9633e..bec0ada4 100644 --- a/src/main/resources/data/basepower/recipe/fluid_storage_tank.json +++ b/src/main/resources/data/basepower/recipe/fluid_storage_tank.json @@ -6,12 +6,8 @@ "xxx" ], "key": { - "x": { - "tag": "forge:ingots/plastic" - }, - "p": { - "tag": "forge:pipes" - } + "x": "#forge:ingots/plastic", + "p": "#forge:pipes" }, "result": { "id": "basepower:fluid_storage_tank" diff --git a/src/main/resources/data/basepower/recipe/fluid_switch.json b/src/main/resources/data/basepower/recipe/fluid_switch.json index 91a47304..177f5e03 100644 --- a/src/main/resources/data/basepower/recipe/fluid_switch.json +++ b/src/main/resources/data/basepower/recipe/fluid_switch.json @@ -5,15 +5,9 @@ "pfp" ], "key": { - "l": { - "item": "minecraft:lever" - }, - "p": { - "tag": "forge:pipes" - }, - "f": { - "tag": "forge:frames/steel" - } + "l": "minecraft:lever", + "p": "#forge:pipes", + "f": "#forge:frames/steel" }, "result": { "id": "basepower:fluid_switch" diff --git a/src/main/resources/data/basepower/recipe/item_conveyor.json b/src/main/resources/data/basepower/recipe/item_conveyor.json index a636d2de..bf3c1ef4 100644 --- a/src/main/resources/data/basepower/recipe/item_conveyor.json +++ b/src/main/resources/data/basepower/recipe/item_conveyor.json @@ -6,15 +6,9 @@ "xxx" ], "key": { - "x": { - "tag": "forge:plates/steel" - }, - "g": { - "tag": "forge:sprockets" - }, - "h": { - "item": "minecraft:hopper" - } + "x": "#forge:plates/steel", + "g": "#forge:sprockets", + "h": "minecraft:hopper" }, "result": { "id": "basepower:item_conveyor", diff --git a/src/main/resources/data/basepower/recipe/item_filter_block.json b/src/main/resources/data/basepower/recipe/item_filter_block.json index ae6d3aaf..a4092bcd 100644 --- a/src/main/resources/data/basepower/recipe/item_filter_block.json +++ b/src/main/resources/data/basepower/recipe/item_filter_block.json @@ -6,16 +6,9 @@ "z" ], "key": { - "x": { - "item": "basepower:item_conveyor" - }, - "y": { - "item": "minecraft:stone", - "data": 0 - }, - "z": { - "item": "minecraft:oak_pressure_plate" - } + "x": "basepower:item_conveyor", + "y": "minecraft:stone", + "z": "minecraft:oak_pressure_plate" }, "result": { "id": "basepower:item_filter_block" diff --git a/src/main/resources/data/basepower/recipe/item_filter_food.json b/src/main/resources/data/basepower/recipe/item_filter_food.json index ab62dcd1..862b32e0 100644 --- a/src/main/resources/data/basepower/recipe/item_filter_food.json +++ b/src/main/resources/data/basepower/recipe/item_filter_food.json @@ -6,15 +6,9 @@ "z" ], "key": { - "x": { - "item": "basepower:item_conveyor" - }, - "y": { - "item": "minecraft:bread" - }, - "z": { - "item": "minecraft:oak_pressure_plate" - } + "x": "basepower:item_conveyor", + "y": "minecraft:bread", + "z": "minecraft:oak_pressure_plate" }, "result": { "id": "basepower:item_filter_food" diff --git a/src/main/resources/data/basepower/recipe/item_filter_fuel.json b/src/main/resources/data/basepower/recipe/item_filter_fuel.json index 114feda3..aa14c9ff 100644 --- a/src/main/resources/data/basepower/recipe/item_filter_fuel.json +++ b/src/main/resources/data/basepower/recipe/item_filter_fuel.json @@ -6,16 +6,9 @@ "z" ], "key": { - "x": { - "item": "basepower:item_conveyor" - }, - "y": { - "item": "minecraft:coal", - "data": 32767 - }, - "z": { - "item": "minecraft:oak_pressure_plate" - } + "x": "basepower:item_conveyor", + "y": "minecraft:coal", + "z": "minecraft:oak_pressure_plate" }, "result": { "id": "basepower:item_filter_fuel" diff --git a/src/main/resources/data/basepower/recipe/item_filter_inventory.json b/src/main/resources/data/basepower/recipe/item_filter_inventory.json index f7a7bc83..47b23950 100644 --- a/src/main/resources/data/basepower/recipe/item_filter_inventory.json +++ b/src/main/resources/data/basepower/recipe/item_filter_inventory.json @@ -6,15 +6,9 @@ "z" ], "key": { - "x": { - "item": "basepower:item_conveyor" - }, - "y": { - "item": "minecraft:chest" - }, - "z": { - "item": "minecraft:oak_pressure_plate" - } + "x": "basepower:item_conveyor", + "y": "minecraft:chest", + "z": "minecraft:oak_pressure_plate" }, "result": { "id": "basepower:item_filter_inventory" diff --git a/src/main/resources/data/basepower/recipe/item_filter_ore.json b/src/main/resources/data/basepower/recipe/item_filter_ore.json index b5279c3f..55f6f536 100644 --- a/src/main/resources/data/basepower/recipe/item_filter_ore.json +++ b/src/main/resources/data/basepower/recipe/item_filter_ore.json @@ -6,15 +6,9 @@ "z" ], "key": { - "x": { - "item": "basepower:item_conveyor" - }, - "y": { - "tag": "forge:ingots/gold" - }, - "z": { - "item": "minecraft:oak_pressure_plate" - } + "x": "basepower:item_conveyor", + "y": "#forge:ingots/gold", + "z": "minecraft:oak_pressure_plate" }, "result": { "id": "basepower:item_filter_ore" diff --git a/src/main/resources/data/basepower/recipe/item_filter_overflow.json b/src/main/resources/data/basepower/recipe/item_filter_overflow.json index aadcac98..0fc8418e 100644 --- a/src/main/resources/data/basepower/recipe/item_filter_overflow.json +++ b/src/main/resources/data/basepower/recipe/item_filter_overflow.json @@ -5,12 +5,8 @@ "z" ], "key": { - "x": { - "item": "basepower:item_conveyor" - }, - "z": { - "item": "minecraft:oak_pressure_plate" - } + "x": "basepower:item_conveyor", + "z": "minecraft:oak_pressure_plate" }, "result": { "id": "basepower:item_filter_overflow" diff --git a/src/main/resources/data/basepower/recipe/item_filter_plant.json b/src/main/resources/data/basepower/recipe/item_filter_plant.json index 2ade8127..005ac4c9 100644 --- a/src/main/resources/data/basepower/recipe/item_filter_plant.json +++ b/src/main/resources/data/basepower/recipe/item_filter_plant.json @@ -6,15 +6,9 @@ "z" ], "key": { - "x": { - "item": "basepower:item_conveyor" - }, - "y": { - "tag": "minecraft:saplings" - }, - "z": { - "item": "minecraft:oak_pressure_plate" - } + "x": "basepower:item_conveyor", + "y": "#minecraft:saplings", + "z": "minecraft:oak_pressure_plate" }, "result": { "id": "basepower:item_filter_plant" diff --git a/src/main/resources/data/basepower/recipe/item_filter_smelt.json b/src/main/resources/data/basepower/recipe/item_filter_smelt.json index cf0383dc..d3a0eff1 100644 --- a/src/main/resources/data/basepower/recipe/item_filter_smelt.json +++ b/src/main/resources/data/basepower/recipe/item_filter_smelt.json @@ -6,15 +6,9 @@ "z" ], "key": { - "x": { - "item": "basepower:item_conveyor" - }, - "y": { - "item": "minecraft:furnace" - }, - "z": { - "item": "minecraft:oak_pressure_plate" - } + "x": "basepower:item_conveyor", + "y": "minecraft:furnace", + "z": "minecraft:oak_pressure_plate" }, "result": { "id": "basepower:item_filter_smelt" diff --git a/src/main/resources/data/basepower/recipe/rotator_tool.json b/src/main/resources/data/basepower/recipe/rotator_tool.json index 8f4b8f7b..314ffcfe 100644 --- a/src/main/resources/data/basepower/recipe/rotator_tool.json +++ b/src/main/resources/data/basepower/recipe/rotator_tool.json @@ -6,12 +6,8 @@ " x" ], "key": { - "x": { - "tag": "forge:ingots/iron" - }, - "*": { - "tag": "forge:sprockets" - } + "x": "#forge:ingots/iron", + "*": "#forge:sprockets" }, "result": { "id": "basepower:rotator_tool" diff --git a/src/main/resources/data/basepower/recipe/sprocket_bronze_nugget_tech_no_rod.json b/src/main/resources/data/basepower/recipe/sprocket_bronze_nugget_tech_no_rod.json index e642b9df..46e77122 100644 --- a/src/main/resources/data/basepower/recipe/sprocket_bronze_nugget_tech_no_rod.json +++ b/src/main/resources/data/basepower/recipe/sprocket_bronze_nugget_tech_no_rod.json @@ -6,12 +6,8 @@ " x " ], "key": { - "x": { - "tag": "forge:ingots/steel" - }, - "/": { - "tag": "forge:nuggets/bronze" - } + "x": "#forge:ingots/steel", + "/": "#forge:nuggets/bronze" }, "result": { "id": "basepower:sprocket", diff --git a/src/main/resources/data/basepower/recipe/sprocket_rod_normal.json b/src/main/resources/data/basepower/recipe/sprocket_rod_normal.json index 1adaae36..efe99b2e 100644 --- a/src/main/resources/data/basepower/recipe/sprocket_rod_normal.json +++ b/src/main/resources/data/basepower/recipe/sprocket_rod_normal.json @@ -6,12 +6,8 @@ " x " ], "key": { - "x": { - "tag": "forge:ingots/steel" - }, - "/": { - "tag": "forge:rods" - } + "x": "#forge:ingots/steel", + "/": "#forge:rods" }, "result": { "id": "basepower:sprocket", diff --git a/src/main/resources/data/basepower/recipe/sprocket_rod_tech.json b/src/main/resources/data/basepower/recipe/sprocket_rod_tech.json index c9454d7c..f9d8e7ab 100644 --- a/src/main/resources/data/basepower/recipe/sprocket_rod_tech.json +++ b/src/main/resources/data/basepower/recipe/sprocket_rod_tech.json @@ -6,12 +6,8 @@ " x " ], "key": { - "x": { - "tag": "forge:ingots/steel" - }, - "/": { - "tag": "forge:rods" - } + "x": "#forge:ingots/steel", + "/": "#forge:rods" }, "result": { "id": "basepower:sprocket", diff --git a/src/main/resources/data/basepower/recipe/sprocket_stickwood_normal.json b/src/main/resources/data/basepower/recipe/sprocket_stickwood_normal.json index 60cdda34..da37d5c0 100644 --- a/src/main/resources/data/basepower/recipe/sprocket_stickwood_normal.json +++ b/src/main/resources/data/basepower/recipe/sprocket_stickwood_normal.json @@ -6,12 +6,8 @@ " x " ], "key": { - "x": { - "tag": "forge:ingots/steel" - }, - "/": { - "tag": "forge:rods/wooden" - } + "x": "#forge:ingots/steel", + "/": "#forge:rods/wooden" }, "result": { "id": "basepower:sprocket", diff --git a/src/main/resources/data/basepower/recipe/starch_from_potato.json b/src/main/resources/data/basepower/recipe/starch_from_potato.json index f06fbdff..e6fc32d0 100644 --- a/src/main/resources/data/basepower/recipe/starch_from_potato.json +++ b/src/main/resources/data/basepower/recipe/starch_from_potato.json @@ -2,12 +2,8 @@ "type": "minecraft:crafting_shapeless", "ingredients": [ [ - { - "item": "minecraft:potato" - }, - { - "item": "minecraft:poisonous_potato" - } + "minecraft:potato", + "minecraft:poisonous_potato" ] ], "result": { diff --git a/src/main/resources/data/basepower/recipe/steel_frame.json b/src/main/resources/data/basepower/recipe/steel_frame.json index b2cbe868..f6cd2821 100644 --- a/src/main/resources/data/basepower/recipe/steel_frame.json +++ b/src/main/resources/data/basepower/recipe/steel_frame.json @@ -6,9 +6,7 @@ "xxx" ], "key": { - "x": { - "tag": "forge:bars/steel" - } + "x": "#forge:bars/steel" }, "result": { "id": "basepower:steel_frame" diff --git a/src/main/resources/data/basepower/recipe/still.json b/src/main/resources/data/basepower/recipe/still.json index 7ef75161..52670f79 100644 --- a/src/main/resources/data/basepower/recipe/still.json +++ b/src/main/resources/data/basepower/recipe/still.json @@ -5,15 +5,9 @@ " f " ], "key": { - "b": { - "item": "minecraft:bucket" - }, - "p": { - "tag": "forge:pipes" - }, - "f": { - "item": "minecraft:furnace" - } + "b": "minecraft:bucket", + "p": "#forge:pipes", + "f": "minecraft:furnace" }, "result": { "id": "basepower:still" diff --git a/src/main/resources/pack.mcmeta b/src/main/resources/pack.mcmeta index f1e3b523..7a8d04dd 100644 --- a/src/main/resources/pack.mcmeta +++ b/src/main/resources/pack.mcmeta @@ -1,7 +1,7 @@ { "pack": { - "description": "Power Advantage resources", - "pack_format": 34, - "supported_formats": { "min_inclusive": 34, "max_inclusive": 48 } + "description": "basePower resources", + "min_format": [94, 1], + "max_format": [94, 1] } } diff --git a/src/test/java/cyano/poweradvantage/api/simple/SimpleMachineGuiNativeTest.java b/src/test/java/cyano/poweradvantage/api/simple/SimpleMachineGuiNativeTest.java new file mode 100644 index 00000000..84c4a29e --- /dev/null +++ b/src/test/java/cyano/poweradvantage/api/simple/SimpleMachineGuiNativeTest.java @@ -0,0 +1,195 @@ +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.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.EntityEquipment; +import net.minecraft.world.entity.player.Inventory; +import org.joml.Matrix3x2f; +import org.joml.Matrix3x2fStack; +import org.joml.Vector2f; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.basepower.math.Integer2D; +import static org.junit.Assert.*; + +/** Native extraction/layout contracts, not GPU glyph or whole-panel screenshot proof. */ +public class SimpleMachineGuiNativeTest { + @BeforeClass public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test public void menuRetainsMachineRowsPlayerGridAndHotbarCoordinates() { + var points = Integer2D.fromCoordinates(12, 34, 12, 55, 124, 45); + var gui = new SimpleMachineGUI(Identifier.parse("basepower:textures/gui/container/fluid_tank_gui.png"), points); + var inventory = new Inventory(null, new EntityEquipment()); + var machine = new SimpleContainer(points.length); + var menu = gui.createMenu(17, inventory, machine); + assertEquals(176, gui.guiWidth); + assertEquals(240, gui.guiHeight); + assertEquals(17, menu.containerId); + assertEquals(points.length + 36, menu.slots.size()); + for (int i = 0; i < points.length; i++) { + var slot = menu.slots.get(i); + assertSame(machine, slot.container); + assertEquals(points[i].X, slot.x); + assertEquals(points[i].Y + 12, slot.y); + } + for (int i = 0; i < 27; i++) { + var slot = menu.slots.get(points.length + i); + assertSame(inventory, slot.container); + assertEquals(8 + (i % 9) * 18, slot.x); + assertEquals(158 + (i / 9) * 18, slot.y); + } + for (int i = 0; i < 9; i++) { + var slot = menu.slots.get(points.length + 27 + i); + assertEquals(8 + i * 18, slot.x); + assertEquals(216, slot.y); + } + } + + @Test public void nativeMatrixBridgeKeepsNeedlePivotRotationAndStackBalance() throws Exception { + var fixture = screen("Machine", "Inventory", 854); + 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()); + for (float value : new float[] {0, 0.5F, 1}) { + var pose = fixture.screen.getMatrices(); + var before = new Matrix3x2f(pose); + pose.pushMatrix(); + try { + pose.translate(80, 55).rotate((value - 0.5F) * (float) Math.PI).translate(-3.5F, -17.5F); + var center = pose.transformPosition(3.5F, 17.5F, new Vector2f()); + assertEquals(80F, center.x, 0.0001F); + assertEquals(55F, center.y, 0.0001F); + var tip = pose.transformPosition(3.5F, 0, new Vector2f()); + assertEquals(value == 0 ? 62.5F : value == 1 ? 97.5F : 80F, tip.x, 0.0001F); + assertEquals(value == 0.5F ? 37.5F : 55F, tip.y, 0.0001F); + } finally { + pose.popMatrix(); + } + assertEquals(before, new Matrix3x2f(pose)); + } + } + + @Test public void captionsReachNativeTextExtractionWithOpaqueWhiteAndSeparateRows() throws Exception { + var fixture = screen("Machine", "Inventory", 854); + fixture.pose.translate(13, 29); + fixture.screen.renderLabels(fixture.graphics, 0, 0); + assertEquals(2, fixture.target.texts.size()); + var title = fixture.target.texts.get(0); + var inventory = fixture.target.texts.get(1); + assertEquals("Machine", text(title)); + assertEquals("Inventory", text(inventory)); + assertEquals(67, title.x); + assertEquals(1, title.y); + assertEquals(8, inventory.x); + assertEquals(140, inventory.y); + for (var caption : fixture.target.texts) { + assertEquals(0xFFFFFFFF, caption.color); + assertTrue(caption.dropShadow); + assertSame(fixture.font, caption.font); + assertEquals(new Matrix3x2f(fixture.pose), new Matrix3x2f(caption.pose)); + } + fixture.pose.translate(50, 50); + assertEquals(13F, title.pose.m20(), 0F); + assertEquals(29F, title.pose.m21(), 0F); + } + + @Test public void longCaptionsKeepTheirAcceptedWidthLimitsAndEllipsis() throws Exception { + var fixture = screen("M".repeat(100), "I".repeat(100), 200); + fixture.screen.renderLabels(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))); + assertEquals(-8, fixture.target.texts.get(0).x); + 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); + } + + 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 font = new MetricsFont(); + set(screen, "graphics", graphics); + set(screen, "playerInventoryLabel", Component.literal(inventory)); + set(screen, "title", Component.literal(title)); + set(screen, "font", font); + set(screen, "width", width); + set(screen, "imageWidth", 176); + return new Fixture(screen, graphics, target, pose, font); + } + + private record Fixture(SimpleMachineGUI.GUIContainer screen, GuiGraphics graphics, + TextQueue target, Matrix3x2fStack pose, MetricsFont font) {} + + private static final class MetricsFont extends Font { + MetricsFont() { super(null); } + @Override public int width(String text) { return text.length() * 6; } + @Override public String plainSubstrByWidth(String text, int width) { + return text.substring(0, Math.min(text.length(), Math.max(0, width / 6))); + } + } + + // 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); } + } + + private static String text(GuiTextRenderState state) { + var text = new StringBuilder(); + state.text.accept((index, style, point) -> { text.appendCodePoint(point); return true; }); + 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/zone/moddev/mc/basepower/gui/FluidTankGuiNativeTest.java b/src/test/java/zone/moddev/mc/basepower/gui/FluidTankGuiNativeTest.java new file mode 100644 index 00000000..83455884 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/gui/FluidTankGuiNativeTest.java @@ -0,0 +1,89 @@ +package zone.moddev.mc.basepower.gui; + +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.RenderPipelines; +import org.joml.Matrix3x2f; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Real native extraction state geometry, not texture upload or screenshot qualification. */ +public class FluidTankGuiNativeTest { + @Test public void displayHeightRetainsBottomAnchoringAndFiniteClamping() throws Exception { + assertEquals(0, FluidTankGUI.gaugeHeight(Float.NaN)); + assertEquals(0, FluidTankGUI.gaugeHeight(Float.POSITIVE_INFINITY)); + assertEquals(0, FluidTankGUI.gaugeHeight(-1)); + assertEquals(0, FluidTankGUI.gaugeHeight(0)); + assertEquals(30, FluidTankGUI.gaugeHeight(0.5F)); + assertEquals(60, FluidTankGUI.gaugeHeight(1)); + assertEquals(60, FluidTankGUI.gaugeHeight(2)); + String source = java.nio.file.Files.readString(java.nio.file.Path.of( + "src/main/java/zone/moddev/mc/basepower/gui/FluidTankGUI.java")); + assertTrue(source.contains("x + xPos, y + yPos + barSlotHeight - h, w, h, zLevel")); + assertTrue(source.contains("guiContainer.blit(displayImage, x + xPos - 3, y + yPos - 3, 176, 0, 22, 66)")); + } + + @Test public void fullGaugeQueuesFourNativeQuadsAndCropsTheLastTileUv() { + 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(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); + assertSame(RenderPipelines.GUI_TEXTURED, tile.pipeline()); + assertEquals(0xFF123456, tile.color()); + } + } + + @Test public void widePartialRectanglePreservesTileGeometryAndBothUvFractions() { + 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(0xFF3F76E4, corner.color()); + } + + @Test public void extractionSnapshotsTransformsAndUsesNativeScissorBounds() { + 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); + assertEquals(1, states.size()); + var state = states.getFirst(); + pose.translate(100, 100); + assertEquals(new Matrix3x2f().translation(10, 20), state.pose()); + assertEquals(scissor, state.scissorArea()); + assertEquals(scissor, state.bounds()); + } + + @Test public void emptyAndInvalidRectanglesSubmitNothing() { + 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()); + } + + private static List extract(Matrix3x2f pose, ScreenRectangle scissor, + int x, int y, int width, int height, int color) { + 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 states = new ArrayList(); + target.forEachElement(state -> states.add((BlitRenderState) state), GuiRenderState.TraverseRange.ALL); + return states; + } + + private static BlitRenderState at(List states, int x, int y) { + return states.stream().filter(state -> state.x0() == x && state.y0() == y).findFirst().orElseThrow(); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/init/NativeBlockItemNamesTest.java b/src/test/java/zone/moddev/mc/basepower/init/NativeBlockItemNamesTest.java new file mode 100644 index 00000000..56994c8f --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/init/NativeBlockItemNamesTest.java @@ -0,0 +1,30 @@ +package zone.moddev.mc.basepower.init; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class NativeBlockItemNamesTest { + @BeforeClass public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + net.minecraftforge.registries.GameData.unfreezeData(); + } + + @Test public void blockItemsUseTheirExistingBlockTranslationKeys() { + for (String name : new String[] {"fluid_drain", "fluid_storage_tank", "item_conveyor"}) { + BlockItem item = new BlockItem(net.minecraft.world.level.block.Blocks.STONE, Blocks.itemProperties(name)); + assertEquals("block.basepower." + name, item.getDescriptionId()); + } + } + + @Test public void ordinaryItemsKeepTheirItemTranslationKeys() { + assertEquals("item.basepower.sprocket", new Item(new Item.Properties().setId( + net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.ITEM, + net.minecraft.resources.Identifier.parse("basepower:sprocket")))).getDescriptionId()); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/machines/conveyors/ConveyorTransferTest.java b/src/test/java/zone/moddev/mc/basepower/machines/conveyors/ConveyorTransferTest.java index fa1c8768..e3e3a521 100644 --- a/src/test/java/zone/moddev/mc/basepower/machines/conveyors/ConveyorTransferTest.java +++ b/src/test/java/zone/moddev/mc/basepower/machines/conveyors/ConveyorTransferTest.java @@ -30,8 +30,8 @@ public class ConveyorTransferTest { for (int n = 0; n < 6; n++) assertTrue(TileEntityConveyor.transferItem(InventoryWrapper.wrap(source), Direction.EAST, InventoryWrapper.wrap(target), Direction.WEST)); assertTrue(source.isEmpty()); - assertEquals(4, target.getItem(0).getCount()); assertEquals("A", zone.moddev.mc.basepower.util.ItemData.customData(target.getItem(0)).getString("Witness")); - assertEquals(3, target.getItem(1).getCount()); assertEquals("B", zone.moddev.mc.basepower.util.ItemData.customData(target.getItem(1)).getString("Witness")); + assertEquals(4, target.getItem(0).getCount()); assertEquals("A", zone.moddev.mc.basepower.util.ItemData.customData(target.getItem(0)).getString("Witness").orElseThrow()); + assertEquals(3, target.getItem(1).getCount()); assertEquals("B", zone.moddev.mc.basepower.util.ItemData.customData(target.getItem(1)).getString("Witness").orElseThrow()); } private static ItemStack tagged(int count, String value) { diff --git a/src/test/java/zone/moddev/mc/basepower/math/BlockPos4DTest.java b/src/test/java/zone/moddev/mc/basepower/math/BlockPos4DTest.java index 3b1e85f4..ca647c48 100644 --- a/src/test/java/zone/moddev/mc/basepower/math/BlockPos4DTest.java +++ b/src/test/java/zone/moddev/mc/basepower/math/BlockPos4DTest.java @@ -2,7 +2,7 @@ import net.minecraft.nbt.IntArrayTag; import net.minecraft.resources.ResourceKey; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.BlockPos; import net.minecraft.core.registries.Registries; import net.minecraft.world.level.Level; @@ -14,8 +14,8 @@ public class BlockPos4DTest { @BeforeClass public static void bootstrap() { net.minecraft.SharedConstants.tryDetectVersion(); net.minecraft.server.Bootstrap.bootStrap(); } @Test public void customDimensionKeysNeverShareCoordinates() { - ResourceKey first = ResourceKey.create(Registries.DIMENSION, ResourceLocation.parse("test:first")); - ResourceKey second = ResourceKey.create(Registries.DIMENSION, ResourceLocation.parse("test:second")); + ResourceKey first = ResourceKey.create(Registries.DIMENSION, Identifier.parse("test:first")); + ResourceKey second = ResourceKey.create(Registries.DIMENSION, Identifier.parse("test:second")); BlockPos4D pos = new BlockPos4D(first, new BlockPos(-17, 80, 32)); assertNotEquals(pos, new BlockPos4D(second, pos.pos)); assertEquals(pos, BlockPos4D.fromNBT(pos.toNBT())); diff --git a/src/test/java/zone/moddev/mc/basepower/migration/AdvantageLegacyMigrationsTest.java b/src/test/java/zone/moddev/mc/basepower/migration/AdvantageLegacyMigrationsTest.java index dfe9ba69..c0d98722 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/AdvantageLegacyMigrationsTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/AdvantageLegacyMigrationsTest.java @@ -6,7 +6,7 @@ import cyano.poweradvantage.api.migration.LegacyMigrationApi; import java.util.Arrays; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import org.junit.Test; public class AdvantageLegacyMigrationsTest { @@ -22,9 +22,9 @@ public class AdvantageLegacyMigrationsTest { "converter_rf_steam", "converter_rf_electricity", "converter_rf_quantum", "converter_tr_electricity")) { assertNotNull(path, LegacyMigrationApi.blockStateCodecs().get( - ResourceLocation.fromNamespaceAndPath("basepower", path))); - assertEquals(ResourceLocation.fromNamespaceAndPath("basepower", path), - LegacyMigrationApi.blockTarget(ResourceLocation.fromNamespaceAndPath("poweradvantage", path))); + Identifier.fromNamespaceAndPath("basepower", path))); + assertEquals(Identifier.fromNamespaceAndPath("basepower", path), + LegacyMigrationApi.blockTarget(Identifier.fromNamespaceAndPath("poweradvantage", path))); } } @@ -38,9 +38,9 @@ public class AdvantageLegacyMigrationsTest { LegacyMigrationApi.tileTarget("minecraft:poweradvantage.tileentity.fluid_storage_tank")); assertEquals("basepower:crude_oil", LegacyMigrationApi.fluidTarget("crude_oil")); assertEquals("basepower:crude_oil", LegacyMigrationApi.fluidTarget("poweradvantage:crude_oil")); - assertEquals(ResourceLocation.fromNamespaceAndPath("basepower", "crude_oil_bucket"), + assertEquals(Identifier.fromNamespaceAndPath("basepower", "crude_oil_bucket"), LegacyMigrationApi.bucketTarget("crude_oil")); - assertEquals(ResourceLocation.fromNamespaceAndPath("basepower", "refined_oil_bucket"), + assertEquals(Identifier.fromNamespaceAndPath("basepower", "refined_oil_bucket"), LegacyMigrationApi.bucketTarget("poweradvantage:refined_oil")); } } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/CooperativeChunkReturnHooksTest.java b/src/test/java/zone/moddev/mc/basepower/migration/CooperativeChunkReturnHooksTest.java new file mode 100644 index 00000000..07de1ac9 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/migration/CooperativeChunkReturnHooksTest.java @@ -0,0 +1,79 @@ +package zone.moddev.mc.basepower.migration; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import static org.junit.Assert.*; + +/** Execute the real handlers: annotation coverage alone missed an early-return data loss. */ +public class CooperativeChunkReturnHooksTest { + @Test public void bothOrdersFinishTheNativeResultWithoutCancellingTheNextHandler() throws Exception { + for (boolean metalsFirst : new boolean[] {true, false}) { + CompoundTag root = new CompoundTag(); + root.putInt("xPos", 0); + root.putInt("zPos", 0); + root.put("entities", new ListTag()); + CompoundTag turbine = new CompoundTag(); + turbine.putString("id", "example:turbine"); + turbine.putIntArray("parentPos", new int[] {8, 5, 8}); + ListTag staged = new ListTag(); + staged.add(turbine); + root.put("AdvantageLegacyEntityDataFix", staged); + String marker = metalsMarker(); + root.putBoolean(marker, true); + String[] order = metalsFirst ? new String[] {"basemetals", "basepower"} + : new String[] {"basepower", "basemetals"}; + for (String mod : order) { + CallbackInfoReturnable callback = new CallbackInfoReturnable<>("upgradeChunkTag", true, root); + invokeHandler(mod, root, callback); + assertFalse(mod + " skipped the remaining migration handlers", callback.isCancelled()); + assertSame(root, callback.getReturnValue()); + } + assertEquals(turbine, root.getListOrEmpty("entities").getCompoundOrEmpty(0)); + assertEquals(1, root.getListOrEmpty("entities").size()); + assertFalse(root.contains("AdvantageLegacyEntityDataFix")); + assertFalse(root.contains(marker)); + assertEquals("full", root.getString("Status").orElseThrow()); + } + } + + private static String metalsMarker() throws Exception { + var type = Class.forName("zone.moddev.mc.basemetals.migration.LegacyWorldDataHook"); + var field = type.getDeclaredField("PRESERVE_CHUNK_MARKER"); + field.setAccessible(true); + return (String) field.get(null); + } + + private static void invokeHandler(String mod, CompoundTag root, CallbackInfoReturnable callback) + throws Exception { + String name = "zone/moddev/mc/" + mod + "/mixin/SimpleRegionStorageMixin"; + ClassNode node = new ClassNode(); + try (var input = CooperativeChunkReturnHooksTest.class.getResourceAsStream("/" + name + ".class")) { + assertNotNull("Missing real migration mixin " + mod, input); + new ClassReader(input).accept(node, 0); + } + // Mixin classes are abstract templates. Make only the test copy instantiable; + // the original handler bytecode and callback semantics are left unchanged. + node.access = (node.access & ~Opcodes.ACC_ABSTRACT) | Opcodes.ACC_PUBLIC; + ClassWriter writer = new ClassWriter(0); + node.accept(writer); + class FixtureLoader extends ClassLoader { + FixtureLoader() { super(CooperativeChunkReturnHooksTest.class.getClassLoader()); } + Class define(byte[] bytes) { return defineClass(name.replace('/', '.'), bytes, 0, bytes.length); } + } + Class type = new FixtureLoader().define(writer.toByteArray()); + Constructor constructor = type.getDeclaredConstructor(); + constructor.setAccessible(true); + Method handler = type.getDeclaredMethod(mod + "$finishChunk", CompoundTag.class, int.class, + CompoundTag.class, CallbackInfoReturnable.class); + handler.setAccessible(true); + handler.invoke(constructor.newInstance(), root, 0, null, callback); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/migration/DataDrivenRegistryMigrationTest.java b/src/test/java/zone/moddev/mc/basepower/migration/DataDrivenRegistryMigrationTest.java index d49f5639..94e403a4 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/DataDrivenRegistryMigrationTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/DataDrivenRegistryMigrationTest.java @@ -5,7 +5,7 @@ import net.minecraft.nbt.NbtIo; import net.minecraft.nbt.NbtAccounter; import net.minecraft.nbt.TagParser; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -15,20 +15,20 @@ public class DataDrivenRegistryMigrationTest { @Rule public TemporaryFolder temporary = new TemporaryFolder(); @Test public void archivesOnlyTheRetiredFlattenedIndexWithoutEditingAnyEnchantments() throws Exception { - var root = TagParser.parseTag("{Data:{DataVersion:3839,Player:{Inventory:[{id:\"basesteam:musket\",count:1," + var root = TagParser.parseCompoundFully("{Data:{DataVersion:3839,Player:{Inventory:[{id:\"basesteam:musket\",count:1," + "components:{\"minecraft:enchantments\":{levels:{\"basesteam:recoil\":3}}}}]}}," + "fml:{Registries:{\"minecraft:enchantment\":{ids:[{K:\"basesteam:recoil\",V:101}," + "{K:\"other:private_enchantment\",V:102}],blocked:[I;105]},\"other:registry\":{opaque:1}}}," + "FML:{Registries:{\"minecraft:enchantments\":{ids:[{K:\"steamadvantage:recoil\",V:77}]}}}}"); var old = root.copy(); assertTrue(LegacyWorldDataHook.archiveDataDrivenEnchantmentIndex(root)); - assertEquals(old.getCompound("Data"), root.getCompound("Data")); - assertEquals(old.getCompound("FML"), root.getCompound("FML")); - assertEquals(old.getCompound("fml").getCompound("Registries").get("other:registry"), - root.getCompound("fml").getCompound("Registries").get("other:registry")); - assertEquals(old.getCompound("fml").getCompound("Registries").get("minecraft:enchantment"), - root.getCompound("basepower:legacy_registry_indexes").get("minecraft:enchantment")); - assertFalse(root.getCompound("fml").getCompound("Registries").contains("minecraft:enchantment")); + assertEquals(old.getCompoundOrEmpty("Data"), root.getCompoundOrEmpty("Data")); + assertEquals(old.getCompoundOrEmpty("FML"), root.getCompoundOrEmpty("FML")); + assertEquals(old.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries").get("other:registry"), + root.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries").get("other:registry")); + assertEquals(old.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries").get("minecraft:enchantment"), + root.getCompoundOrEmpty("basepower:legacy_registry_indexes").get("minecraft:enchantment")); + assertFalse(root.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries").contains("minecraft:enchantment")); var once = root.copy(); assertFalse(LegacyWorldDataHook.archiveDataDrivenEnchantmentIndex(root)); assertEquals(once, root); @@ -37,7 +37,7 @@ public class DataDrivenRegistryMigrationTest { @Test public void filePreparationKeepsAnExactArchiveAcrossAnotherSaveAndRestart() throws Exception { var world = temporary.newFolder().toPath(); var file = world.resolve("level.dat"); - var source = TagParser.parseTag("{Data:{DataVersion:3839},fml:{Registries:" + var source = TagParser.parseCompoundFully("{Data:{DataVersion:3839},fml:{Registries:" + "{\"minecraft:enchantment\":{ids:[{K:\"basesteam:recoil\",V:101}]}}}}"); NbtIo.writeCompressed(source, file); LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); @@ -45,9 +45,9 @@ public class DataDrivenRegistryMigrationTest { NbtAccounter.unlimitedHeap())); var archive = world.resolve("basepower-retired-registry-indexes.dat"); byte[] bytes = Files.readAllBytes(archive); - assertEquals(source.getCompound("fml").getCompound("Registries"), + assertEquals(source.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries"), NbtIo.readCompressed(archive, NbtAccounter.unlimitedHeap())); - var current = TagParser.parseTag("{Data:{DataVersion:3955},fml:{Registries:{}}}"); + var current = TagParser.parseCompoundFully("{Data:{DataVersion:3955},fml:{Registries:{}}}"); NbtIo.writeCompressed(current, file); LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); assertArrayEquals(bytes, Files.readAllBytes(archive)); @@ -58,20 +58,20 @@ public class DataDrivenRegistryMigrationTest { net.minecraft.SharedConstants.tryDetectVersion(); net.minecraft.server.Bootstrap.bootStrap(); var lookup = net.minecraft.data.registries.VanillaRegistries.createLookup() .lookupOrThrow(net.minecraft.core.registries.Registries.PAINTING_VARIANT); - var registry = ResourceLocation.parse("minecraft:painting_variant"); + var registry = Identifier.parse("minecraft:painting_variant"); int found = 0; for (var holder : lookup.listElements().toList()) { - if (VanillaRegistryRemaps.retiredPaintingIndex(registry, holder.key().location())) found++; + if (VanillaRegistryRemaps.retiredPaintingIndex(registry, holder.key().identifier())) found++; } assertEquals(30, found); - assertFalse(VanillaRegistryRemaps.retiredPaintingIndex(registry, ResourceLocation.parse("other:alban"))); - assertFalse(VanillaRegistryRemaps.retiredPaintingIndex(registry, ResourceLocation.parse("minecraft:unknown"))); - assertFalse(VanillaRegistryRemaps.retiredPaintingIndex(ResourceLocation.parse("minecraft:item"), - ResourceLocation.parse("minecraft:alban"))); + assertFalse(VanillaRegistryRemaps.retiredPaintingIndex(registry, Identifier.parse("other:alban"))); + assertFalse(VanillaRegistryRemaps.retiredPaintingIndex(registry, Identifier.parse("minecraft:unknown"))); + assertFalse(VanillaRegistryRemaps.retiredPaintingIndex(Identifier.parse("minecraft:item"), + Identifier.parse("minecraft:alban"))); } @Test public void aConflictingArchiveIsNeverReplacedOrSilentlyDiscarded() throws Exception { - var root = TagParser.parseTag("{fml:{Registries:{\"minecraft:enchantment\":{ids:[{K:\"basesteam:recoil\",V:3}]}}}," + var root = TagParser.parseCompoundFully("{fml:{Registries:{\"minecraft:enchantment\":{ids:[{K:\"basesteam:recoil\",V:3}]}}}," + "\"basepower:legacy_registry_indexes\":{\"minecraft:enchantment\":{ids:[{K:\"other:different\",V:4}]}}}"); var before = root.copy(); try { @@ -95,7 +95,7 @@ public class DataDrivenRegistryMigrationTest { var field = net.minecraft.world.level.Level.class.getDeclaredField("registryAccess"); field.setAccessible(true); field.set(world, new net.minecraft.core.RegistryAccess.ImmutableRegistryAccess(java.util.List.of(paintings)).freeze()); - var tag = TagParser.parseTag("{id:\"minecraft:painting\",UUID:[I;1,2,3,4],variant:\"minecraft:alban\"," + var tag = TagParser.parseCompoundFully("{id:\"minecraft:painting\",UUID:[I;1,2,3,4],variant:\"minecraft:alban\"," + "facing:1b,Pos:[10d,80d,12d],Motion:[0d,0d,0d],Rotation:[0f,0f],TileX:10,TileY:80,TileZ:12}"); var root = new CompoundTag(); root.putInt("DataVersion", 3839); @@ -104,13 +104,17 @@ public class DataDrivenRegistryMigrationTest { root.put("Entities", entities); LegacyEntityDataFixes.prepare(root); LegacyEntityDataFixes.finish(root); - var converted = root.getList("Entities", 10).getCompound(0); - var entity = new net.minecraft.world.entity.decoration.Painting(net.minecraft.world.entity.EntityType.PAINTING, world); - entity.load(converted); - var saved = entity.saveWithoutId(new CompoundTag()); - assertEquals(ResourceLocation.parse("minecraft:alban"), entity.getVariant().unwrapKey().orElseThrow().location()); - assertEquals("minecraft:alban", saved.getString("variant")); - assertEquals(1, saved.getByte("facing")); - assertEquals(tag.getUUID("UUID"), entity.getUUID()); + var converted = (CompoundTag) net.minecraft.util.datafix.DataFixers.getDataFixer().update( + net.minecraft.util.datafix.fixes.References.ENTITY, + new com.mojang.serialization.Dynamic<>(net.minecraft.nbt.NbtOps.INSTANCE, + root.getListOrEmpty("Entities").getCompoundOrEmpty(0)), 3839, + net.minecraft.SharedConstants.getCurrentVersion().dataVersion().version()).getValue(); + var entity = new net.minecraft.world.entity.decoration.painting.Painting(net.minecraft.world.entity.EntityType.PAINTING, world); + NativeWolfTestWorld.load(entity, converted); + var saved = NativeWolfTestWorld.save(entity); + assertEquals(Identifier.parse("minecraft:alban"), entity.getVariant().unwrapKey().orElseThrow().identifier()); + assertEquals("minecraft:alban", saved.getStringOr("variant", "")); + assertEquals(1, saved.getByteOr("facing", (byte)0)); + assertEquals(tag.read("UUID", net.minecraft.core.UUIDUtil.CODEC).orElseThrow(), entity.getUUID()); } } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/FlattenedBiomeSnapshotTest.java b/src/test/java/zone/moddev/mc/basepower/migration/FlattenedBiomeSnapshotTest.java index 807c4a22..b99a9237 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/FlattenedBiomeSnapshotTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/FlattenedBiomeSnapshotTest.java @@ -8,9 +8,9 @@ public class FlattenedBiomeSnapshotTest { @Test public void preservesEveryNumericIdAndUnknownModEntry() { CompoundTag root = root(1631); - CompoundTag original = registries(root).getCompound("minecraft:biomes").copy(); + CompoundTag original = registries(root).getCompoundOrEmpty("minecraft:biomes").copy(); assertTrue(LegacyWorldDataHook.renameFlattenedBiomeRegistry(root)); - assertEquals(original, registries(root).getCompound("minecraft:worldgen/biome")); + assertEquals(original, registries(root).getCompoundOrEmpty("minecraft:worldgen/biome")); assertFalse(registries(root).contains("minecraft:biomes")); } @@ -35,7 +35,7 @@ public class FlattenedBiomeSnapshotTest { root.put("FML", archive); CompoundTag before = archive.copy(); assertTrue(LegacyWorldDataHook.renameFlattenedBiomeRegistry(root)); - assertEquals(before, root.getCompound("FML")); + assertEquals(before, root.getCompoundOrEmpty("FML")); } @Test public void refusesConflictingSnapshotsWithoutChangingEither() { @@ -52,7 +52,7 @@ public class FlattenedBiomeSnapshotTest { @Test public void coalescesOnlyAnIdenticalDuplicate() { CompoundTag root = root(2230); - registries(root).put("minecraft:worldgen/biome", registries(root).getCompound("minecraft:biomes").copy()); + registries(root).put("minecraft:worldgen/biome", registries(root).getCompoundOrEmpty("minecraft:biomes").copy()); assertTrue(LegacyWorldDataHook.renameFlattenedBiomeRegistry(root)); assertFalse(registries(root).contains("minecraft:biomes")); } @@ -84,6 +84,6 @@ private static CompoundTag root(int version) { } private static CompoundTag registries(CompoundTag root) { - return root.getCompound("fml").getCompound("Registries"); + return root.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries"); } } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/FlattenedRegistryNameNormalizationTest.java b/src/test/java/zone/moddev/mc/basepower/migration/FlattenedRegistryNameNormalizationTest.java new file mode 100644 index 00000000..eb2138bf --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/migration/FlattenedRegistryNameNormalizationTest.java @@ -0,0 +1,254 @@ +package zone.moddev.mc.basepower.migration; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.minecraft.SharedConstants; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.TagParser; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import net.minecraftforge.registries.ForgeRegistries; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class FlattenedRegistryNameNormalizationTest { + private static final String ARCHIVE = "basepower:legacy_registry_indexes"; + private static final Map ALIASES = Map.of( + "minecraft:potions", "minecraft:mob_effect", + "minecraft:potiontypes", "minecraft:potion", + "minecraft:soundevents", "minecraft:sound_event", + "minecraft:enchantments", "minecraft:enchantment"); + + @Rule public TemporaryFolder temporary = new TemporaryFolder(); + + @BeforeClass public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test public void historicalEffectAndPotionCensusesResolveToDistinctNativeRegistryTypes() { + assertEquals(id("mob_effect"), ForgeRegistries.MOB_EFFECTS.getRegistryName()); + assertEquals(id("potion"), ForgeRegistries.POTIONS.getRegistryName()); + assertEquals(id("sound_event"), ForgeRegistries.SOUND_EVENTS.getRegistryName()); + assertEquals(id("enchantment"), Registries.ENCHANTMENT.identifier()); + assertTrue(ForgeRegistries.MOB_EFFECTS.containsKey(id("blindness"))); + assertFalse("Historical potions are effects, not potion types", ForgeRegistries.POTIONS.containsKey(id("blindness"))); + assertTrue(ForgeRegistries.POTIONS.containsKey(id("awkward"))); + assertFalse(ForgeRegistries.MOB_EFFECTS.containsKey(id("awkward"))); + assertTrue(ForgeRegistries.SOUND_EVENTS.containsKey(id("ambient.cave"))); + } + + @Test public void normalizesFourLowercaseKeysBeforeArchivingWithoutChangingAnyPayload() throws Exception { + CompoundTag before = source(), expected = normalized(before); + Path file = write(before); + LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); + CompoundTag actual = read(file); + assertEquals(expected, actual); + assertEquals(before.get("Data"), actual.get("Data")); + assertEquals("Numeric pre-flattening archive is separate and untouched", before.get("FML"), actual.get("FML")); + for (String old : ALIASES.keySet()) assertFalse(old, registries(actual).contains(old)); + assertFalse("Data-driven enchantment index must not reach static Forge GameData", registries(actual).contains("minecraft:enchantment")); + assertEquals(registries(before).get("minecraft:enchantments"), actual.getCompoundOrEmpty(ARCHIVE).get("minecraft:enchantment")); + assertEquals(actual.getCompoundOrEmpty(ARCHIVE), read(file.resolveSibling("basepower-retired-registry-indexes.dat"))); + } + + @Test public void keepsEverySteamAndUnknownModEnchantmentIncludingSnapshotMetadata() throws Exception { + CompoundTag before = source(); + Path file = write(before); + LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); + CompoundTag actual = read(file).getCompoundOrEmpty(ARCHIVE).getCompoundOrEmpty("minecraft:enchantment"); + assertEquals(registries(before).get("minecraft:enchantments"), actual); + assertEquals(5, actual.getListOrEmpty("ids").size()); + for (String name : List.of("steamadvantage:high_explosive", "steamadvantage:powderless", + "steamadvantage:rapid_reload", "steamadvantage:recoil", "othermod:private_enchantment")) { + assertTrue(name, actual.getListOrEmpty("ids").stream().anyMatch(tag -> + ((CompoundTag)tag).getString("K").orElseThrow().equals(name))); + } + assertArrayEquals(new int[]{77, 901}, actual.getIntArray("blocked").orElseThrow()); + assertEquals(1, actual.getListOrEmpty("overrides").size()); + assertEquals(1, actual.getListOrEmpty("aliases").size()); + assertEquals(1, actual.getListOrEmpty("dummied").size()); + } + + @Test public void identicalCanonicalDuplicatesCoalesceWithoutMergingOrRenumberingEntries() throws Exception { + CompoundTag before = source(); + for (var alias : ALIASES.entrySet()) { + registries(before).put(alias.getValue(), registries(before).get(alias.getKey()).copy()); + } + Path file = write(before); + LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); + assertEquals(normalized(before), read(file)); + } + + @Test public void anyConflictingCanonicalDuplicateFailsBeforeWritingOrCreatingAnArchive() throws Exception { + for (var alias : ALIASES.entrySet()) { + CompoundTag input = source(); + CompoundTag conflicting = ((CompoundTag)registries(input).get(alias.getKey())).copy(); + conflicting.putIntArray("blocked", new int[]{123456}); + registries(input).put(alias.getValue(), conflicting); + assertRejectedWithoutWrites(input, false); + } + } + + @Test public void malformedRecognizedSourceOrCanonicalSnapshotsFailClosed() throws Exception { + for (var alias : ALIASES.entrySet()) { + for (String key : List.of(alias.getKey(), alias.getValue())) { + CompoundTag input = source(); + registries(input).putString(key, "NotARegistrySnapshot"); + assertRejectedWithoutWrites(input, false); + } + } + } + + @Test public void aConflictingEmbeddedEnchantmentArchiveCannotDiscardThePluralIndex() throws Exception { + CompoundTag input = source(); + CompoundTag archive = new CompoundTag(); + archive.put("minecraft:enchantment", snapshot("othermod:different")); + input.put(ARCHIVE, archive); + assertRejectedWithoutWrites(input, false); + } + + @Test public void aConflictingArchiveFileIsRetainedAndLevelDataIsNeverPartiallyNormalized() throws Exception { + assertRejectedWithoutWrites(source(), true); + } + + @Test public void originalBackupAndSidecarRemainByteExactAcrossRepeatPreparationAndSavedRestart() throws Exception { + CompoundTag input = source(); + Path file = write(input); + byte[] original = Files.readAllBytes(file); + LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); + Path backup = file.resolveSibling("level.dat.basepower-legacy-backup"); + Path sidecar = file.resolveSibling("basepower-retired-registry-indexes.dat"); + assertTrue("Normalization must create the original level backup", Files.isRegularFile(backup)); + assertTrue("The complete enchantment index must be archived", Files.isRegularFile(sidecar)); + assertArrayEquals(original, Files.readAllBytes(backup)); + byte[] once = Files.readAllBytes(file), archive = Files.readAllBytes(sidecar); + LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); + assertArrayEquals("Second preparation must not rewrite level.dat", once, Files.readAllBytes(file)); + assertArrayEquals(original, Files.readAllBytes(backup)); + assertArrayEquals(archive, Files.readAllBytes(sidecar)); + CompoundTag saved = read(file); + saved.getCompoundOrEmpty("Data").putInt("DataVersion", 4671); + saved.getCompoundOrEmpty("Data").putLong("Time", 123456); + NbtIo.writeCompressed(saved, file); + byte[] restarted = Files.readAllBytes(file); + LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); + assertArrayEquals(restarted, Files.readAllBytes(file)); + assertArrayEquals(original, Files.readAllBytes(backup)); + assertArrayEquals(archive, Files.readAllBytes(sidecar)); + assertEquals(input.get("FML"), read(file).get("FML")); + } + + @Test public void unknownRegistryNamesAndModNamespacesAreNeverNormalizedFilteredOrArchived() throws Exception { + CompoundTag input = source(); + for (String key : ALIASES.keySet()) registries(input).remove(key); + registries(input).put("minecraft:private_registry", snapshot("othermod:unknown_entry")); + registries(input).put("othermod:potions", snapshot("othermod:unknown_potion")); + Path file = write(input); + byte[] before = Files.readAllBytes(file); + LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); + assertArrayEquals(before, Files.readAllBytes(file)); + assertEquals(input, read(file)); + assertFalse(Files.exists(file.resolveSibling("level.dat.basepower-legacy-backup"))); + assertFalse(Files.exists(file.resolveSibling("basepower-retired-registry-indexes.dat"))); + } + + @Test public void preFlatteningAndUppercaseOnlyArchivesAreNotReinterpreted() throws Exception { + CompoundTag preFlattening = source(); + preFlattening.getCompoundOrEmpty("Data").putInt("DataVersion", 1343); + CompoundTag uppercaseOnly = source(); + uppercaseOnly.remove("fml"); + for (CompoundTag input : List.of(preFlattening, uppercaseOnly)) { + Path file = write(input); + byte[] before = Files.readAllBytes(file); + LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); + assertArrayEquals(before, Files.readAllBytes(file)); + assertEquals(input, read(file)); + } + } + + private void assertRejectedWithoutWrites(CompoundTag input, boolean existingSidecar) throws Exception { + Path file = write(input), sidecar = file.resolveSibling("basepower-retired-registry-indexes.dat"); + if (existingSidecar) NbtIo.writeCompressed(snapshot("othermod:conflicting_archive"), sidecar); + byte[] original = Files.readAllBytes(file), oldSidecar = existingSidecar ? Files.readAllBytes(sidecar) : null; + try { + LegacyWorldDataHook.prepareForgeRegistryMetadata(file.toFile()); + fail("Conflicting or malformed registry snapshots must not be silently accepted"); + } catch (IllegalStateException expected) { + assertArrayEquals(original, Files.readAllBytes(file)); + assertFalse("No write was committed", Files.exists(file.resolveSibling("level.dat.basepower-legacy-backup"))); + if (existingSidecar) assertArrayEquals(oldSidecar, Files.readAllBytes(sidecar)); + else assertFalse(Files.exists(sidecar)); + } + } + + private Path write(CompoundTag input) throws Exception { + Path file = temporary.newFolder().toPath().resolve("level.dat"); + NbtIo.writeCompressed(input, file); + return file; + } + + private static CompoundTag read(Path file) throws Exception { + return NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap()); + } + + private static CompoundTag normalized(CompoundTag input) { + CompoundTag expected = input.copy(), registries = registries(expected); + for (var alias : ALIASES.entrySet()) { + registries.put(alias.getValue(), registries.get(alias.getKey()).copy()); + registries.remove(alias.getKey()); + } + CompoundTag archive = expected.getCompoundOrEmpty(ARCHIVE); + archive.put("minecraft:enchantment", registries.get("minecraft:enchantment").copy()); + expected.put(ARCHIVE, archive); + registries.remove("minecraft:enchantment"); + return expected; + } + + private static CompoundTag source() throws Exception { + CompoundTag root = TagParser.parseCompoundFully("{Data:{DataVersion:1631,Time:400L,Player:{Inventory:" + + "[{id:\"steamadvantage:musket\",Count:1b,tag:{ench:[{id:\"steamadvantage:recoil\",lvl:3s}]}}]}}," + + "fml:{Registries:{}},FML:{Registries:{\"minecraft:enchantments\":{ids:[{K:\"steamadvantage:recoil\",V:77}]}," + + "\"minecraft:blocks\":{ids:[{K:\"poweradvantage:still\",V:255}]}},OpaqueLegacyField:[I;1,2,3]}}").copy(); + CompoundTag registries = registries(root); + registries.put("minecraft:potions", snapshot("minecraft:blindness", "othermod:private_effect")); + registries.put("minecraft:potiontypes", snapshot("minecraft:awkward", "othermod:private_potion")); + registries.put("minecraft:soundevents", snapshot("minecraft:ambient.cave", "othermod:private_sound")); + registries.put("minecraft:enchantments", snapshot("steamadvantage:high_explosive", "steamadvantage:powderless", + "steamadvantage:rapid_reload", "steamadvantage:recoil", "othermod:private_enchantment")); + registries.put("othermod:private_registry", snapshot("othermod:opaque_entry")); + return root; + } + + private static CompoundTag snapshot(String... names) throws Exception { + CompoundTag snapshot = TagParser.parseCompoundFully("{ids:[],blocked:[I;77,901]," + + "aliases:[{K:\"othermod:old_alias\",V:\"othermod:new_alias\"}]," + + "overrides:[{K:\"minecraft:opaque_entry\",V:\"othermod\"}],dummied:[\"othermod:missing\"]," + + "Opaque:{Bytes:[B;1b,2b],Counter:123L}}"); + ListTag ids = new ListTag(); + int index = 0; + for (String name : names) { + CompoundTag entry = new CompoundTag(); + entry.putString("K", name); + entry.putInt("V", 200 + index++); + ids.add(entry); + } + snapshot.put("ids", ids); + return snapshot; + } + + private static CompoundTag registries(CompoundTag root) { + return root.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries"); + } + + private static Identifier id(String name) { return Identifier.fromNamespaceAndPath("minecraft", name); } +} diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyEnchantmentsTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyEnchantmentsTest.java index 134e19ad..9ab88138 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyEnchantmentsTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyEnchantmentsTest.java @@ -24,10 +24,10 @@ public class LegacyEnchantmentsTest { } tag.put("ench", list); assertTrue(LegacyWorldDataHook.migrateLegacyEnchantments(tag, saved)); - assertFalse(tag.contains("ench", 9)); + assertFalse(zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "ench", 9)); for (int i = 0; i < 4; i++) { - assertEquals(saved.get(10 + i), tag.getList("Enchantments", 10).getCompound(i).getString("id")); - assertEquals(3, tag.getList("Enchantments", 10).getCompound(i).getShort("lvl")); + assertEquals(saved.get(10 + i), tag.getListOrEmpty("Enchantments").getCompoundOrEmpty(i).getStringOr("id", "")); + assertEquals(3, tag.getListOrEmpty("Enchantments").getCompoundOrEmpty(i).getShortOr("lvl", (short)0)); } assertFalse(LegacyWorldDataHook.migrateLegacyEnchantments(tag, saved)); } @@ -43,8 +43,8 @@ public class LegacyEnchantmentsTest { list.add(entry); book.put("StoredEnchantments", list); assertTrue(LegacyWorldDataHook.migrateLegacyEnchantments(book, saved)); - assertEquals("steamadvantage:rapid_reload", book.getList("StoredEnchantments", 10).getCompound(0).getString("id")); - assertEquals(2, book.getList("StoredEnchantments", 10).getCompound(0).getShort("lvl")); + assertEquals("steamadvantage:rapid_reload", book.getListOrEmpty("StoredEnchantments").getCompoundOrEmpty(0).getStringOr("id", "")); + assertEquals(2, book.getListOrEmpty("StoredEnchantments").getCompoundOrEmpty(0).getShortOr("lvl", (short)0)); } @Test(expected = IllegalStateException.class) diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyEntitiesTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyEntitiesTest.java index cf778deb..c64307e6 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyEntitiesTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyEntitiesTest.java @@ -20,6 +20,27 @@ public class LegacyEntitiesTest { assertWolf(false); } + @Test public void stagedEntitiesSurviveTheIntermediateLegacyFlatteningAndFullNativeFix() { + CompoundTag turbine = entity("example:turbine"); + turbine.putIntArray("parentPos", new int[] {860, 5, -964}); + turbine.putLong("UUIDMost", 17L); + turbine.putLong("UUIDLeast", 19L); + CompoundTag root = chunk(turbine, 512); + LegacyWorldDataHook.prepareLegacyChunk(root); + LegacyWorldDataHook.prepareLegacyEntities(root); + root = DataFixTypes.CHUNK.update(DataFixers.getDataFixer(), root, 512, 1493); + root.putInt("DataVersion", 1493); + assertTrue("The intermediate chunk must retain its isolated entity payload", + root.contains("AdvantageLegacyEntityDataFix")); + CompoundTag converted = convert(root.copy(), 1493); + ListTag actual = converted.getListOrEmpty("entities"); + assertEquals(1, actual.size()); + assertEquals("example:turbine", actual.getCompoundOrEmpty(0).getString("id").orElseThrow()); + assertArrayEquals(new int[] {860, 5, -964}, actual.getCompoundOrEmpty(0).getIntArray("parentPos").orElseThrow()); + assertEquals(new UUID(17L, 19L), actual.getCompoundOrEmpty(0).read("UUID", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); + assertFalse(converted.contains("AdvantageLegacyEntityDataFix")); + } + @Test public void retainsWolfBesideCustomEntityInDirect110Chunk() { assertWolf(true); } @@ -39,15 +60,15 @@ public class LegacyEntitiesTest { vehicle.put("Passengers", passengers); CompoundTag root = chunk(vehicle, 512); root = convert(root, 512); - CompoundTag actual = root.getList("entities", 10).getCompound(0); - assertEquals("example:vehicle", actual.getString("id")); - assertEquals("keep-verbatim", actual.getString("CustomState")); - ListTag fixed = actual.getList("Passengers", 10); + CompoundTag actual = root.getListOrEmpty("entities").getCompoundOrEmpty(0); + assertEquals("example:vehicle", actual.getStringOr("id", "")); + assertEquals("keep-verbatim", actual.getStringOr("CustomState", "")); + ListTag fixed = actual.getListOrEmpty("Passengers"); assertEquals(2, fixed.size()); - assertEquals("minecraft:wolf", fixed.getCompound(0).getString("id")); - assertEquals(UUID.fromString(wolf.getString("OwnerUUID")), fixed.getCompound(0).getUUID("Owner")); - assertEquals("example:seat", fixed.getCompound(1).getString("id")); - assertEquals("minecraft:cow", fixed.getCompound(1).getList("Passengers", 10).getCompound(0).getString("id")); + assertEquals("minecraft:wolf", fixed.getCompoundOrEmpty(0).getStringOr("id", "")); + assertEquals(UUID.fromString(wolf.getStringOr("OwnerUUID", "")), fixed.getCompoundOrEmpty(0).read("Owner", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); + assertEquals("example:seat", fixed.getCompoundOrEmpty(1).getStringOr("id", "")); + assertEquals("minecraft:cow", fixed.getCompoundOrEmpty(1).getListOrEmpty("Passengers").getCompoundOrEmpty(0).getStringOr("id", "")); } @Test public void usesVanillaSubtypeFixersRatherThanLowercasingOldIds() { @@ -55,17 +76,17 @@ public class LegacyEntitiesTest { skeleton.putByte("SkeletonType", (byte) 1); CompoundTag root = chunk(skeleton, 512); root = convert(root, 512); - assertEquals("minecraft:wither_skeleton", root.getList("entities", 10).getCompound(0).getString("id")); + assertEquals("minecraft:wither_skeleton", root.getListOrEmpty("entities").getCompoundOrEmpty(0).getStringOr("id", "")); } @Test public void leavesModernEntityAndUnrelatedCustomNbtUnchangedOnRepeatedPreparation() { CompoundTag wolf = entity("minecraft:wolf"); wolf.putString("OwnerUUID", "a614e658-6270-33c3-9297-7ce80908964f"); wolf.put("CustomData", entity("Wolf")); - CompoundTag root = chunk(wolf, SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + CompoundTag root = chunk(wolf, SharedConstants.getCurrentVersion().dataVersion().version()); LegacyWorldDataHook.prepareLegacyChunk(root); LegacyWorldDataHook.prepareLegacyChunk(root); - assertEquals(wolf, root.getCompound("Level").getList("Entities", 10).getCompound(0)); + assertEquals(wolf, root.getCompoundOrEmpty("Level").getListOrEmpty("Entities").getCompoundOrEmpty(0)); } @Test public void repeatPreparationDoesNotLoseConvertedVanillaState() { @@ -120,12 +141,12 @@ private static void assertWolf(boolean customEntity) { level.put("Entities", entities); root.put("Level", level); CompoundTag fixed = convert(root, 512); - CompoundTag actual = fixed.getList("entities", 10).getCompound(0); - assertEquals("minecraft:wolf", actual.getString("id")); - assertEquals(UUID.fromString(wolf.getString("OwnerUUID")), actual.getUUID("Owner")); - assertTrue(actual.getBoolean("Sitting")); - assertEquals(new UUID(123L, 456L), actual.getUUID("UUID")); - assertEquals(customEntity ? 2 : 1, fixed.getList("entities", 10).size()); + CompoundTag actual = fixed.getListOrEmpty("entities").getCompoundOrEmpty(0); + assertEquals("minecraft:wolf", actual.getStringOr("id", "")); + assertEquals(UUID.fromString(wolf.getStringOr("OwnerUUID", "")), actual.read("Owner", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); + assertTrue(actual.getBooleanOr("Sitting", false)); + assertEquals(new UUID(123L, 456L), actual.read("UUID", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); + assertEquals(customEntity ? 2 : 1, fixed.getListOrEmpty("entities").size()); } @Test public void preservesCustomEntityUuidAndPrivateNbtThroughWholeChunkFix() { @@ -138,9 +159,9 @@ private static void assertWolf(boolean customEntity) { custom.put("PrivateData", privateData); for (int version : new int[] {512, 1343, 1631, 1976, 2230}) { CompoundTag converted = convert(chunk(custom.copy(), version), version); - CompoundTag actual = converted.getList("entities", 10).getCompound(0); - assertEquals(new UUID(-9014291490481488382L, -5032249493117728499L), actual.getUUID("UUID")); - assertEquals(privateData, actual.getCompound("PrivateData")); + CompoundTag actual = converted.getListOrEmpty("entities").getCompoundOrEmpty(0); + assertEquals(new UUID(-9014291490481488382L, -5032249493117728499L), actual.read("UUID", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); + assertEquals(privateData, actual.getCompoundOrEmpty("PrivateData")); assertFalse(converted.contains("AdvantageLegacyEntityDataFix")); } } @@ -156,15 +177,15 @@ private static void assertWolf(boolean customEntity) { wolf.putFloat("Health", 8F); wolf.putBoolean("Sitting", true); CompoundTag root = chunk(wolf, version); - root.getCompound("Level").getList("Entities", 10).add(entity("example:turbine")); + root.getCompoundOrEmpty("Level").getListOrEmpty("Entities").add(entity("example:turbine")); CompoundTag converted = convert(root, version); - CompoundTag actual = converted.getList("entities", 10).getCompound(0); - assertEquals(uuid, actual.getUUID("UUID")); - assertEquals(owner, actual.getUUID("Owner")); - assertEquals(8F, actual.getFloat("Health"), 0F); - assertTrue(actual.getBoolean("Sitting")); - converted.putInt("DataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); - assertEquals(converted, convert(converted.copy(), SharedConstants.getCurrentVersion().getDataVersion().getVersion())); + CompoundTag actual = converted.getListOrEmpty("entities").getCompoundOrEmpty(0); + assertEquals(uuid, actual.read("UUID", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); + assertEquals(owner, actual.read("Owner", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); + assertEquals(8F, actual.getFloatOr("Health", 0.0F), 0F); + assertTrue(actual.getBooleanOr("Sitting", false)); + converted.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); + assertEquals(converted, convert(converted.copy(), SharedConstants.getCurrentVersion().dataVersion().version())); } } @@ -172,7 +193,7 @@ private static CompoundTag convert(CompoundTag root, int version) { LegacyWorldDataHook.prepareLegacyChunk(root); LegacyWorldDataHook.prepareLegacyEntities(root); CompoundTag fixed = DataFixTypes.CHUNK.update(DataFixers.getDataFixer(), - root, version, SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + root, version, SharedConstants.getCurrentVersion().dataVersion().version()); return LegacyWorldDataHook.finalizeLegacyChunk(fixed); } @@ -182,14 +203,14 @@ private static CompoundTag convert(CompoundTag root, int version) { custom.putLong("UUIDMost", uuid.getMostSignificantBits()); custom.putLong("UUIDLeast", uuid.getLeastSignificantBits()); assertEquals(uuid, convert(chunk(custom, 512), 512) - .getList("entities", 10).getCompound(0).getUUID("UUID")); + .getListOrEmpty("entities").getCompoundOrEmpty(0).read("UUID", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); } CompoundTag custom = entity("example:vehicle"); - custom.putUUID("UUID", new UUID(17L, 19L)); + custom.store("UUID", net.minecraft.core.UUIDUtil.CODEC, new UUID(17L, 19L)); custom.putLong("UUIDMost", 88L); custom.putLong("UUIDLeast", 99L); assertEquals(new UUID(17L, 19L), convert(chunk(custom, 2230), 2230) - .getList("entities", 10).getCompound(0).getUUID("UUID")); + .getListOrEmpty("entities").getCompoundOrEmpty(0).read("UUID", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); } @Test public void baseMetalsDurabilitySurvivesBothEntryHookOrders() throws Exception { @@ -210,18 +231,18 @@ private static CompoundTag convert(CompoundTag root, int version) { if (!baseFirst) basePrepare.invoke(null, root); LegacyWorldDataHook.prepareLegacyEntities(root); root = DataFixTypes.CHUNK.update(DataFixers.getDataFixer(), - root, 1343, SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + root, 1343, SharedConstants.getCurrentVersion().dataVersion().version()); root = LegacyWorldDataHook.finalizeLegacyChunk(root); - CompoundTag actual = root.getList("entities", 10).getCompound(0) - .getList("Items", 10).getCompound(0); + CompoundTag actual = root.getListOrEmpty("entities").getCompoundOrEmpty(0) + .getListOrEmpty("Items").getCompoundOrEmpty(0); String order = (baseFirst ? "Base Metals first: " : "basePower first: ") + actual; - assertEquals(order, "basemetals:steel_pickaxe", actual.getString("id")); - assertEquals(order, 1, actual.getInt("count")); + assertEquals(order, "basemetals:steel_pickaxe", actual.getStringOr("id", "")); + assertEquals(order, 1, actual.getIntOr("count", 0)); assertFalse(order, actual.contains("Count")); - assertTrue(order, actual.contains("Slot", 1)); - assertEquals(order, 0, actual.getByte("Slot")); - assertEquals(order, 17, actual.getCompound("components").getInt("minecraft:damage")); - int current = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + assertTrue(order, zone.moddev.mc.basepower.util.NbtCompat.containsTag(actual, "Slot", 1)); + assertEquals(order, 0, actual.getByteOr("Slot", (byte)0)); + assertEquals(order, 17, actual.getCompoundOrEmpty("components").getIntOr("minecraft:damage", 0)); + int current = SharedConstants.getCurrentVersion().dataVersion().version(); root.putInt("DataVersion", current); assertEquals(order, root, convert(root.copy(), current)); } @@ -231,7 +252,7 @@ private static CompoundTag convert(CompoundTag root, int version) { CompoundTag root = chunk(entity("example:vehicle"), 512); ListTag wrong = new ListTag(); wrong.add(StringTag.valueOf("keep")); - root.getCompound("Level").put("Entities", wrong); + root.getCompoundOrEmpty("Level").put("Entities", wrong); LegacyWorldDataHook.prepareLegacyEntities(root); } @@ -252,15 +273,15 @@ private static CompoundTag convert(CompoundTag root, int version) { } @Test public void detachesPendingChunkNbtIncludingNestedMapsAndArrays() { - CompoundTag pending = chunk(entity("example:vehicle"), SharedConstants.getCurrentVersion().getDataVersion().getVersion()); - pending.getCompound("Level").putIntArray("Biomes", new int[] {1, 2, 3}); + CompoundTag pending = chunk(entity("example:vehicle"), SharedConstants.getCurrentVersion().dataVersion().version()); + pending.getCompoundOrEmpty("Level").putIntArray("Biomes", new int[] {1, 2, 3}); CompoundTag before = pending.copy(); CompoundTag read = LegacyWorldDataHook.copyChunkForMigration(pending); assertNotSame(pending, read); - assertNotSame(pending.getCompound("Level"), read.getCompound("Level")); - read.getCompound("Level").putBoolean("TerrainPopulated", true); - read.getCompound("Level").getList("Entities", 10).getCompound(0).putInt("test", 1); - read.getCompound("Level").getIntArray("Biomes")[0] = 19; + assertNotSame(pending.getCompoundOrEmpty("Level"), read.getCompoundOrEmpty("Level")); + read.getCompoundOrEmpty("Level").putBoolean("TerrainPopulated", true); + read.getCompoundOrEmpty("Level").getListOrEmpty("Entities").getCompoundOrEmpty(0).putInt("test", 1); + read.getCompoundOrEmpty("Level").getIntArray("Biomes").orElseThrow()[0] = 19; assertEquals(before, pending); assertNull(LegacyWorldDataHook.copyChunkForMigration(null)); } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyEntityAttributeAliasesTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyEntityAttributeAliasesTest.java index f0fc8837..244eb3a1 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyEntityAttributeAliasesTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyEntityAttributeAliasesTest.java @@ -9,10 +9,9 @@ import java.util.UUID; import java.util.function.UnaryOperator; import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.animal.Wolf; +import net.minecraft.world.entity.animal.wolf.Wolf; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.NbtUtils; import net.minecraft.nbt.NbtOps; import net.minecraft.nbt.StringTag; import net.minecraft.SharedConstants; @@ -39,9 +38,9 @@ public class LegacyEntityAttributeAliasesTest { CompoundTag root = chunk(entity); LegacyEntityDataFixes.prepare(root); LegacyEntityDataFixes.finish(root); - CompoundTag actual = root.getCompound("Level").getList("Entities", 10).getCompound(0); + CompoundTag actual = root.getCompoundOrEmpty("Level").getListOrEmpty("Entities").getCompoundOrEmpty(0); CompoundTag expected = before.copy(); - expected.getList("Attributes", 10).getCompound(0).putString("Name", "forge:swim_speed"); + expected.getListOrEmpty("Attributes").getCompoundOrEmpty(0).putString("Name", "forge:swim_speed"); expected = LegacyForgeAttributeRegistryTest.nativeAttributeRecord(expected); assertEquals(expected, actual); assertEquals(before, entity); @@ -51,14 +50,14 @@ public class LegacyEntityAttributeAliasesTest { CompoundTag root = chunk(entity("minecraft:wolf", true)); LegacyEntityDataFixes.prepare(root); LegacyEntityDataFixes.finish(root); - CompoundTag attribute = root.getCompound("Level").getList("Entities", 10).getCompound(0) - .getList("attributes", 10).getCompound(0); - assertEquals("forge:swim_speed", attribute.getString("id")); - assertEquals(2D, attribute.getDouble("base"), 0D); - assertEquals("keep-attribute-field", attribute.getString("PrivateNote")); - CompoundTag modifier = attribute.getList("modifiers", 10).getCompound(0); - assertEquals("minecraft:" + MODIFIER, modifier.getString("id")); - assertEquals("keep-modifier-field", modifier.getString("PrivateNote")); + CompoundTag attribute = root.getCompoundOrEmpty("Level").getListOrEmpty("Entities").getCompoundOrEmpty(0) + .getListOrEmpty("attributes").getCompoundOrEmpty(0); + assertEquals("forge:swim_speed", attribute.getStringOr("id", "")); + assertEquals(2D, attribute.getDoubleOr("base", 0.0D), 0D); + assertEquals("keep-attribute-field", attribute.getStringOr("PrivateNote", "")); + CompoundTag modifier = attribute.getListOrEmpty("modifiers").getCompoundOrEmpty(0); + assertEquals("minecraft:" + MODIFIER, modifier.getStringOr("id", "")); + assertEquals("keep-modifier-field", modifier.getStringOr("PrivateNote", "")); } @Test public void aliasesOnlyEntityPassengersNotInventoryOrPrivateCompounds() { @@ -72,12 +71,12 @@ public class LegacyEntityAttributeAliasesTest { ListTag items = new ListTag(); items.add(foreign.copy()); parent.put("Items", items); CompoundTag root = chunk(parent); LegacyEntityDataFixes.prepare(root); LegacyEntityDataFixes.finish(root); - CompoundTag actual = root.getCompound("Level").getList("Entities", 10).getCompound(0); - assertEquals("forge:swim_speed", actual.getList("Passengers", 10).getCompound(0) - .getList("attributes", 10).getCompound(0).getString("id")); - assertEquals(foreign, actual.getCompound("PrivateData")); - assertEquals(foreign, actual.getCompound("ForgeData")); - assertEquals(items, actual.getList("Items", 10)); + CompoundTag actual = root.getCompoundOrEmpty("Level").getListOrEmpty("Entities").getCompoundOrEmpty(0); + assertEquals("forge:swim_speed", actual.getListOrEmpty("Passengers").getCompoundOrEmpty(0) + .getListOrEmpty("attributes").getCompoundOrEmpty(0).getStringOr("id", "")); + assertEquals(foreign, actual.getCompoundOrEmpty("PrivateData")); + assertEquals(foreign, actual.getCompoundOrEmpty("ForgeData")); + assertEquals(items, actual.getListOrEmpty("Items")); } @Test public void rejectsLegacyNativeCollisionWithoutChangingChunk() { @@ -97,7 +96,7 @@ public class LegacyEntityAttributeAliasesTest { } entity.put("Attributes", list); CompoundTag root = chunk(entity), before = root.copy(); - before.getCompound("Level").getList("Entities", 10).set(0, + before.getCompoundOrEmpty("Level").getListOrEmpty("Entities").set(0, LegacyForgeAttributeRegistryTest.nativeAttributeRecord(entity)); LegacyEntityDataFixes.prepare(root); LegacyEntityDataFixes.finish(root); assertEquals(before, root); @@ -116,12 +115,12 @@ public class LegacyEntityAttributeAliasesTest { @Test public void modernChunksAndPlayersRemainUnchanged() { CompoundTag root = chunk(entity("minecraft:wolf", true)); - root.putInt("DataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + root.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); CompoundTag before = root.copy(); LegacyEntityDataFixes.prepare(root); assertEquals(before, root); CompoundTag player = player(entity("minecraft:wolf", true)); CompoundTag result = LegacyPlayerDataFixes.update(DataFixers.getDataFixer(), DataFixTypes.PLAYER, - player, SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + player, SharedConstants.getCurrentVersion().dataVersion().version()); assertEquals(player, result); } @@ -130,17 +129,17 @@ public class LegacyEntityAttributeAliasesTest { ListTag passengers = new ListTag(); passengers.add(entity("example:seat", true)); vehicle.put("Passengers", passengers); CompoundTag source = player(vehicle), expected = source.copy(); CompoundTag result = LegacyPlayerDataFixes.update(new RecordingFixer(value -> { - CompoundTag rootVehicle = value.getCompound("RootVehicle").getCompound("Entity"); - assertEquals("forge:swim_speed", rootVehicle.getList("Attributes", 10).getCompound(0).getString("Name")); - assertEquals("forge:swim_speed", rootVehicle.getList("Passengers", 10).getCompound(0) - .getList("Attributes", 10).getCompound(0).getString("Name")); + CompoundTag rootVehicle = value.getCompoundOrEmpty("RootVehicle").getCompoundOrEmpty("Entity"); + assertEquals("forge:swim_speed", rootVehicle.getListOrEmpty("Attributes").getCompoundOrEmpty(0).getStringOr("Name", "")); + assertEquals("forge:swim_speed", rootVehicle.getListOrEmpty("Passengers").getCompoundOrEmpty(0) + .getListOrEmpty("Attributes").getCompoundOrEmpty(0).getStringOr("Name", "")); return value; }), DataFixTypes.PLAYER, source, 512); assertEquals(expected, source); - expected.getCompound("RootVehicle").getCompound("Entity").getList("Attributes", 10) - .getCompound(0).putString("Name", "forge:swim_speed"); - expected.getCompound("RootVehicle").getCompound("Entity").getList("Passengers", 10).getCompound(0) - .getList("Attributes", 10).getCompound(0).putString("Name", "forge:swim_speed"); + expected.getCompoundOrEmpty("RootVehicle").getCompoundOrEmpty("Entity").getListOrEmpty("Attributes") + .getCompoundOrEmpty(0).putString("Name", "forge:swim_speed"); + expected.getCompoundOrEmpty("RootVehicle").getCompoundOrEmpty("Entity").getListOrEmpty("Passengers").getCompoundOrEmpty(0) + .getListOrEmpty("Attributes").getCompoundOrEmpty(0).putString("Name", "forge:swim_speed"); assertEquals(expected, result); } @@ -154,15 +153,15 @@ public class LegacyEntityAttributeAliasesTest { CompoundTag result = LegacyPlayerDataFixes.update(new RecordingFixer(value -> value), DataFixTypes.PLAYER, player, 1343); assertEquals(before, player); - assertEquals(inventory, result.getList("Inventory", 10)); - assertEquals(before.getCompound("PrivateData"), result.getCompound("PrivateData")); + assertEquals(inventory, result.getListOrEmpty("Inventory")); + assertEquals(before.getCompoundOrEmpty("PrivateData"), result.getCompoundOrEmpty("PrivateData")); } @Test public void rootVehiclePassengerCollisionFailsWithoutChangingPlayer() { CompoundTag vehicle = entity("example:vehicle", true); CompoundTag child = entity("example:seat", true); CompoundTag duplicate = attribute(false); duplicate.putString("Name", "forge:swim_speed"); - child.getList("Attributes", 10).add(duplicate); + child.getListOrEmpty("Attributes").add(duplicate); ListTag passengers = new ListTag(); passengers.add(child); vehicle.put("Passengers", passengers); CompoundTag player = player(vehicle), before = player.copy(); try { @@ -176,36 +175,36 @@ public class LegacyEntityAttributeAliasesTest { ListTag passengers = new ListTag(); passengers.add(entity("minecraft:wolf", true)); vehicle.put("Passengers", passengers); CompoundTag player = player(vehicle), before = player.copy(); CompoundTag result = LegacyPlayerDataFixes.update(DataFixers.getDataFixer(), DataFixTypes.PLAYER, player, 1343); - CompoundTag fixed = result.getCompound("RootVehicle").getCompound("Entity"); - for (CompoundTag actual : new CompoundTag[] {fixed, fixed.getList("Passengers", 10).getCompound(0)}) { - CompoundTag attribute = actual.getList("attributes", 10).getCompound(0); - assertEquals("forge:swim_speed", attribute.getString("id")); - assertEquals(2D, attribute.getDouble("base"), 0D); - assertEquals("minecraft:" + MODIFIER, attribute.getList("modifiers", 10).getCompound(0).getString("id")); + CompoundTag fixed = result.getCompoundOrEmpty("RootVehicle").getCompoundOrEmpty("Entity"); + for (CompoundTag actual : new CompoundTag[] {fixed, fixed.getListOrEmpty("Passengers").getCompoundOrEmpty(0)}) { + CompoundTag attribute = actual.getListOrEmpty("attributes").getCompoundOrEmpty(0); + assertEquals("forge:swim_speed", attribute.getStringOr("id", "")); + assertEquals(2D, attribute.getDoubleOr("base", 0.0D), 0D); + assertEquals("minecraft:" + MODIFIER, attribute.getListOrEmpty("modifiers").getCompoundOrEmpty(0).getStringOr("id", "")); } assertEquals(before, player); } @Test public void ownPlayerAttributeBaseTwoAndModifierSurviveNativePlayerDfu() { CompoundTag player = player(entity("minecraft:wolf", true)); - player.put("Attributes", entity("minecraft:wolf", true).getList("Attributes", 10)); + player.put("Attributes", entity("minecraft:wolf", true).getListOrEmpty("Attributes")); CompoundTag foreign = entity("example:private", true); player.put("PrivateData", foreign.copy()); CompoundTag before = player.copy(); CompoundTag result = LegacyPlayerDataFixes.update(DataFixers.getDataFixer(), DataFixTypes.PLAYER, player, 1343); - CompoundTag attribute = result.getList("attributes", 10).getCompound(0); - assertEquals("forge:swim_speed", attribute.getString("id")); - assertEquals(2D, attribute.getDouble("base"), 0D); - assertEquals("minecraft:" + MODIFIER, attribute.getList("modifiers", 10).getCompound(0).getString("id")); - assertEquals("keep-attribute-field", attribute.getString("PrivateNote")); - assertEquals("keep-modifier-field", attribute.getList("modifiers", 10).getCompound(0).getString("PrivateNote")); - assertEquals(foreign, result.getCompound("PrivateData")); + CompoundTag attribute = result.getListOrEmpty("attributes").getCompoundOrEmpty(0); + assertEquals("forge:swim_speed", attribute.getStringOr("id", "")); + assertEquals(2D, attribute.getDoubleOr("base", 0.0D), 0D); + assertEquals("minecraft:" + MODIFIER, attribute.getListOrEmpty("modifiers").getCompoundOrEmpty(0).getStringOr("id", "")); + assertEquals("keep-attribute-field", attribute.getStringOr("PrivateNote", "")); + assertEquals("keep-modifier-field", attribute.getListOrEmpty("modifiers").getCompoundOrEmpty(0).getStringOr("PrivateNote", "")); + assertEquals(foreign, result.getCompoundOrEmpty("PrivateData")); assertEquals(before, player); } @Test public void ownPlayerAttributeCollisionFailsWithoutMutatingCaller() { CompoundTag player = player(entity("example:vehicle", true)); - ListTag attributes = entity("example:attributes", true).getList("Attributes", 10); + ListTag attributes = entity("example:attributes", true).getListOrEmpty("Attributes"); CompoundTag duplicate = attribute(false); duplicate.putString("Name", "forge:swim_speed"); attributes.add(duplicate); player.put("Attributes", attributes); CompoundTag before = player.copy(); @@ -230,16 +229,16 @@ public class LegacyEntityAttributeAliasesTest { source.putFloat("Health", 8F); CompoundTag red = (CompoundTag) DataFixers.getDataFixer().update(References.ENTITY, new Dynamic<>(NbtOps.INSTANCE, source.copy()), 1343, - SharedConstants.getCurrentVersion().getDataVersion().getVersion()).getValue(); - Wolf before = new Wolf(EntityType.WOLF, world); before.load(red); + SharedConstants.getCurrentVersion().dataVersion().version()).getValue(); + Wolf before = new Wolf(EntityType.WOLF, world); NativeWolfTestWorld.load(before, red); var swimSpeed = ForgeMod.SWIM_SPEED.getHolder().orElseThrow(); assertEquals(1D, before.getAttribute(swimSpeed).getBaseValue(), 0D); CompoundTag root = chunk(source); LegacyEntityDataFixes.prepare(root); LegacyEntityDataFixes.finish(root); Wolf after = new Wolf(EntityType.WOLF, world); - after.load(root.getCompound("Level").getList("Entities", 10).getCompound(0)); + NativeWolfTestWorld.load(after, root.getCompoundOrEmpty("Level").getListOrEmpty("Entities").getCompoundOrEmpty(0)); assertEquals(2D, after.getAttribute(swimSpeed).getBaseValue(), 0D); - var modifierId = net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("minecraft", MODIFIER.toString()); + var modifierId = net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", MODIFIER.toString()); assertNotNull(after.getAttribute(swimSpeed).getModifier(modifierId)); assertEquals(0.75D, after.getAttribute(swimSpeed).getModifier(modifierId).amount(), 0D); assertEquals(2.75D, after.getAttributeValue(swimSpeed), 0D); @@ -248,9 +247,9 @@ public class LegacyEntityAttributeAliasesTest { private static void assertCollision(String first, String second) { CompoundTag entity = entity("example:vehicle", true); - entity.getList("Attributes", 10).getCompound(0).putString("Name", first); + entity.getListOrEmpty("Attributes").getCompoundOrEmpty(0).putString("Name", first); CompoundTag duplicate = attribute(false); duplicate.putString("Name", second); - entity.getList("Attributes", 10).add(duplicate); + entity.getListOrEmpty("Attributes").add(duplicate); CompoundTag root = chunk(entity), before = root.copy(); try { LegacyEntityDataFixes.prepare(root); fail("Alias collision accepted"); } catch (IllegalStateException expected) { assertEquals(before, root); } @@ -282,7 +281,7 @@ private static CompoundTag chunk(CompoundTag entity) { private static CompoundTag player(CompoundTag entity) { CompoundTag result = new CompoundTag(); CompoundTag vehicle = new CompoundTag(); - vehicle.put("Entity", entity); vehicle.putUUID("Attach", new UUID(1L, 2L)); result.put("RootVehicle", vehicle); return result; + vehicle.put("Entity", entity); vehicle.store("Attach", net.minecraft.core.UUIDUtil.CODEC, new UUID(1L, 2L)); result.put("RootVehicle", vehicle); return result; } private static void registerForgeAttributes() throws Exception { diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyForgeAttributeRegistryTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyForgeAttributeRegistryTest.java index 39c892e7..cc254966 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyForgeAttributeRegistryTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyForgeAttributeRegistryTest.java @@ -6,7 +6,7 @@ import java.util.UUID; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.ai.attributes.AttributeInstance; -import net.minecraft.world.entity.animal.Wolf; +import net.minecraft.world.entity.animal.wolf.Wolf; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; import net.minecraft.nbt.NbtOps; @@ -17,7 +17,7 @@ import net.minecraft.server.Bootstrap; import net.minecraft.server.level.ServerLevel; import net.minecraftforge.common.ForgeMod; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.registries.ForgeRegistries; import org.junit.BeforeClass; import org.junit.Test; @@ -25,10 +25,10 @@ public class LegacyForgeAttributeRegistryTest { private static final String[][] ALIASES = { - {"forge.swimSpeed", "forge:swim_speed"}, - {"forge.nameTagDistance", "forge:nametag_distance"}, - {"forge.entity_gravity", "minecraft:generic.gravity"}, - {"generic.reachDistance", "minecraft:player.block_interaction_range"} + {"forge.swimSpeed", "forge:swim_speed", "forge:swim_speed"}, + {"forge.nameTagDistance", "forge:nametag_distance", "forge:nametag_distance"}, + {"forge.entity_gravity", "minecraft:generic.gravity", "minecraft:gravity"}, + {"generic.reachDistance", "minecraft:player.block_interaction_range", "minecraft:block_interaction_range"} }; @BeforeClass public static void bootstrap() throws Exception { @@ -50,7 +50,7 @@ static void registerForgeAttributes() throws ReflectiveOperationException { CompoundTag root = chunk(source); LegacyEntityDataFixes.prepare(root); LegacyEntityDataFixes.finish(root); CompoundTag expected = before.copy(); - expected.getList("Attributes", 10).getCompound(0).putString("Name", alias[1]); + expected.getListOrEmpty("Attributes").getCompoundOrEmpty(0).putString("Name", alias[1]); expected = nativeAttributeRecord(expected); assertEquals(alias[0], expected, fixedEntity(root)); assertEquals(before, source); @@ -64,14 +64,14 @@ static void registerForgeAttributes() throws ReflectiveOperationException { CompoundTag source = entity(ALIASES[index][0], values[index]); source.putString("id", "minecraft:wolf"); CompoundTag raw = (CompoundTag) DataFixers.getDataFixer().update(References.ENTITY, new Dynamic<>(NbtOps.INSTANCE, source.copy()), 1343, - SharedConstants.getCurrentVersion().getDataVersion().getVersion()).getValue(); - Wolf negative = new Wolf(EntityType.WOLF, world); negative.load(raw); + SharedConstants.getCurrentVersion().dataVersion().version()).getValue(); + Wolf negative = new Wolf(EntityType.WOLF, world); NativeWolfTestWorld.load(negative, raw); AttributeInstance old = negative.getAttribute(ForgeRegistries.ATTRIBUTES.getHolder( - ResourceLocation.parse(ALIASES[index][1])).orElseThrow()); + Identifier.parse(ALIASES[index][2])).orElseThrow()); assertNotEquals(ALIASES[index][0], values[index], old.getBaseValue(), 0D); CompoundTag root = chunk(source); LegacyEntityDataFixes.prepare(root); LegacyEntityDataFixes.finish(root); - Wolf positive = new Wolf(EntityType.WOLF, world); positive.load(fixedEntity(root)); + Wolf positive = new Wolf(EntityType.WOLF, world); NativeWolfTestWorld.load(positive, fixedEntity(root)); AttributeInstance actual = positive.getAttribute(old.getAttribute()); assertEquals(ALIASES[index][0], values[index], actual.getBaseValue(), 0D); assertEquals(0.125D, actual.getModifier(modifierId()).amount(), 0D); @@ -83,13 +83,13 @@ static void registerForgeAttributes() throws ReflectiveOperationException { CompoundTag source = entity("generic.reachDistance", 7.5D), before = source.copy(); CompoundTag actual = LegacyPlayerDataFixes.update(DataFixers.getDataFixer(), DataFixTypes.PLAYER, source, 1343); - CompoundTag value = actual.getList("attributes", 10).getCompound(0); - assertEquals("minecraft:player.block_interaction_range", value.getString("id")); + CompoundTag value = actual.getListOrEmpty("attributes").getCompoundOrEmpty(0); + assertEquals("minecraft:block_interaction_range", value.getStringOr("id", "")); var reach = net.minecraft.world.entity.ai.attributes.Attributes.BLOCK_INTERACTION_RANGE; assertSame(reach.value(), ForgeRegistries.ATTRIBUTES.getValue( - ResourceLocation.parse(value.getString("id")))); + Identifier.parse(value.getStringOr("id", "")))); AttributeInstance nativeAttribute = new AttributeInstance(reach, unused -> {}); - nativeAttribute.load(value); + nativeAttribute.apply(AttributeInstance.Packed.CODEC.parse(NbtOps.INSTANCE, value).getOrThrow()); assertEquals(7.5D, nativeAttribute.getBaseValue(), 0D); assertEquals(7.625D, nativeAttribute.getValue(), 0D); assertEquals(0.125D, nativeAttribute.getModifier(modifierId()).amount(), 0D); @@ -100,7 +100,7 @@ static void registerForgeAttributes() throws ReflectiveOperationException { for (String[] alias : ALIASES) for (String[] pair : new String[][] { {alias[0], alias[1]}, {alias[0], alias[0]}, {alias[1], alias[1]}}) { CompoundTag source = entity(pair[0], 2D); - source.getList("Attributes", 10).add(attribute(pair[1], 3D)); + source.getListOrEmpty("Attributes").add(attribute(pair[1], 3D)); CompoundTag root = chunk(source), before = root.copy(); try { LegacyEntityDataFixes.prepare(root); fail("Ambiguous attribute accepted: " + alias[0]); } catch (IllegalStateException expected) { assertEquals(before, root); } @@ -114,19 +114,19 @@ static void registerForgeAttributes() throws ReflectiveOperationException { CompoundTag passenger = entity(alias[0], 5.5D); passenger.putString("id", "minecraft:wolf"); ListTag passengers = new ListTag(); passengers.add(passenger); vehicle.put("Passengers", passengers); CompoundTag rootVehicle = new CompoundTag(); rootVehicle.put("Entity", vehicle); - rootVehicle.putUUID("Attach", new UUID(23L, 29L)); source.put("RootVehicle", rootVehicle); + rootVehicle.store("Attach", net.minecraft.core.UUIDUtil.CODEC, new UUID(23L, 29L)); source.put("RootVehicle", rootVehicle); source.put("PrivateData", entity(alias[0], 4.5D)); CompoundTag before = source.copy(); CompoundTag actual = LegacyPlayerDataFixes.update(DataFixers.getDataFixer(), DataFixTypes.PLAYER, source, 1343); - CompoundTag fixedVehicle = actual.getCompound("RootVehicle").getCompound("Entity"); + CompoundTag fixedVehicle = actual.getCompoundOrEmpty("RootVehicle").getCompoundOrEmpty("Entity"); for (CompoundTag item : new CompoundTag[] {actual, fixedVehicle, - fixedVehicle.getList("Passengers", 10).getCompound(0)}) { - assertEquals(alias[1], item.getList("attributes", 10).getCompound(0).getString("id")); - assertEquals(0.125D, item.getList("attributes", 10).getCompound(0) - .getList("modifiers", 10).getCompound(0).getDouble("amount"), 0D); + fixedVehicle.getListOrEmpty("Passengers").getCompoundOrEmpty(0)}) { + assertEquals(alias[2], item.getListOrEmpty("attributes").getCompoundOrEmpty(0).getStringOr("id", "")); + assertEquals(0.125D, item.getListOrEmpty("attributes").getCompoundOrEmpty(0) + .getListOrEmpty("modifiers").getCompoundOrEmpty(0).getDoubleOr("amount", 0.0D), 0D); } - assertEquals(before.getCompound("PrivateData"), actual.getCompound("PrivateData")); + assertEquals(before.getCompoundOrEmpty("PrivateData"), actual.getCompoundOrEmpty("PrivateData")); assertEquals(before, source); } } @@ -144,17 +144,17 @@ static void registerForgeAttributes() throws ReflectiveOperationException { @Test public void modernChunksAndSimilarUnknownNamesRemainUntouched() { for (String[] alias : ALIASES) { CompoundTag root = chunk(entity(alias[0], 7D)); - root.putInt("DataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + root.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); CompoundTag before = root.copy(); LegacyEntityDataFixes.prepare(root); assertEquals(before, root); root = chunk(entity(alias[0] + ".private", 7D)); before = root.copy(); - before.getCompound("Level").getList("Entities", 10).set(0, - nativeAttributeRecord(before.getCompound("Level").getList("Entities", 10).getCompound(0))); + before.getCompoundOrEmpty("Level").getListOrEmpty("Entities").set(0, + nativeAttributeRecord(before.getCompoundOrEmpty("Level").getListOrEmpty("Entities").getCompoundOrEmpty(0))); LegacyEntityDataFixes.prepare(root); LegacyEntityDataFixes.finish(root); assertEquals(before, root); } } - private static ResourceLocation modifierId() { - return ResourceLocation.fromNamespaceAndPath("minecraft", new UUID(17L, 19L).toString()); + private static Identifier modifierId() { + return Identifier.fromNamespaceAndPath("minecraft", new UUID(17L, 19L).toString()); } static CompoundTag nativeAttributeRecord(CompoundTag source) { @@ -176,12 +176,12 @@ static CompoundTag nativeAttributeRecord(CompoundTag source) { LegacyEntityDataFixes.finish(root); CompoundTag converted = fixedEntity(root); Wolf nativeReader = new Wolf(EntityType.WOLF, NativeWolfTestWorld.create()); - nativeReader.load(converted); + NativeWolfTestWorld.load(nativeReader, converted); AttributeInstance speed = nativeReader.getAttribute(ForgeMod.SWIM_SPEED.getHolder().orElseThrow()); assertEquals(2D, speed.getBaseValue(), 0D); assertNotNull(speed.getModifier(modifierId())); assertEquals(2.125D, speed.getValue(), 0D); - assertEquals(original, converted.getCompound("PrivateData")); + assertEquals(original, converted.getCompoundOrEmpty("PrivateData")); } private static CompoundTag entity(String name, double base) { @@ -207,6 +207,6 @@ private static CompoundTag chunk(CompoundTag entity) { level.put("Entities", entities); value.put("Level", level); return value; } - private static CompoundTag fixedEntity(CompoundTag root) { return root.getCompound("Level").getList("Entities", 10).getCompound(0); } + private static CompoundTag fixedEntity(CompoundTag root) { return root.getCompoundOrEmpty("Level").getListOrEmpty("Entities").getCompoundOrEmpty(0); } } 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 c05cd17a..f71087e9 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyMachineItemsTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyMachineItemsTest.java @@ -42,10 +42,11 @@ public class LegacyMachineItemsTest { CompoundTag tile = convertedTile(stack("minecraft:wool", 14, 7)); var previous = zone.moddev.mc.basepower.init.Entities.CONVEYOR; try { - zone.moddev.mc.basepower.init.Entities.CONVEYOR = net.minecraft.world.level.block.entity.BlockEntityType.Builder - .of(TileEntityConveyor::new, Blocks.HOPPER).build(null); + zone.moddev.mc.basepower.init.Entities.CONVEYOR = new net.minecraft.world.level.block.entity.BlockEntityType<>( + TileEntityConveyor::new, java.util.Set.of(Blocks.HOPPER)); TileEntityConveyor conveyor = new TileEntityConveyor(net.minecraft.core.BlockPos.ZERO, Blocks.HOPPER.defaultBlockState()); - conveyor.loadWithComponents(tile, registries()); + conveyor.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create( + net.minecraft.util.ProblemReporter.DISCARDING, registries(), tile)); assertSame(Items.RED_WOOL, conveyor.getItem(0).getItem()); assertEquals(7, conveyor.getItem(0).getCount()); } finally { zone.moddev.mc.basepower.init.Entities.CONVEYOR = previous; } @@ -60,10 +61,10 @@ private static net.minecraft.core.HolderLookup.Provider registries() { {"minecraft:dye", "4", "minecraft:lapis_lazuli"}, {"minecraft:planks", "1", "minecraft:spruce_planks"}}) { CompoundTag item = convertedTile(stack(row[0], Integer.parseInt(row[1]), 7)) - .getList("Items", 10).getCompound(0); + .getListOrEmpty("Items").getCompoundOrEmpty(0); assertStack(row[2], 7, item); - assertEquals(0, item.getByte("Slot")); - assertTrue("Missing inventory slot in " + item, item.contains("Slot", 1)); + assertEquals(0, item.getByteOr("Slot", (byte)0)); + assertTrue("Missing inventory slot in " + item, zone.moddev.mc.basepower.util.NbtCompat.containsTag(item, "Slot", 1)); } } @@ -80,11 +81,11 @@ private static net.minecraft.core.HolderLookup.Provider registries() { display.putString("Name", "Survey Pick"); tag.put("display", display); pick.put("tag", tag); - CompoundTag actual = convertedTile(pick).getList("Items", 10).getCompound(0); + CompoundTag actual = convertedTile(pick).getListOrEmpty("Items").getCompoundOrEmpty(0); assertStack("minecraft:diamond_pickaxe", 1, actual); - assertEquals(17, actual.getCompound("components").getInt("minecraft:damage")); + assertEquals(17, actual.getCompoundOrEmpty("components").getIntOr("minecraft:damage", 0)); assertEnchantment(actual, "minecraft:enchantments", "minecraft:efficiency", 3); - ItemStack loaded = ItemStack.parseOptional(registries(), actual); + ItemStack loaded = parseStack(actual); assertSame(Items.DIAMOND_PICKAXE, loaded.getItem()); assertEquals(1, loaded.getCount()); assertEquals(17, loaded.getDamageValue()); @@ -101,11 +102,11 @@ private static net.minecraft.core.HolderLookup.Provider registries() { tile.put("Recipe", nested); tile.putFloat("Energy", 19.5F); tile.putString("CustomName", "keep"); - CompoundTag actual = convert(chunk(tile), 512).getList("block_entities", 10).getCompound(0); - assertStack("minecraft:lapis_lazuli", 1, actual.getCompound("Recipe").getCompound("Reference")); - assertStack("minecraft:red_wool", 7, actual.getList("Items", 10).getCompound(0)); - assertEquals(19.5F, actual.getFloat("Energy"), 0F); - assertEquals("keep", actual.getString("CustomName")); + CompoundTag actual = convert(chunk(tile), 512).getListOrEmpty("block_entities").getCompoundOrEmpty(0); + assertStack("minecraft:lapis_lazuli", 1, actual.getCompoundOrEmpty("Recipe").getCompoundOrEmpty("Reference")); + assertStack("minecraft:red_wool", 7, actual.getListOrEmpty("Items").getCompoundOrEmpty(0)); + assertEquals(19.5F, actual.getFloatOr("Energy", 0.0F), 0F); + assertEquals("keep", actual.getStringOr("CustomName", "")); } @Test public void foreignPrivatePayloadsAreNotInterpretedAsAdvantageInventories() { @@ -115,7 +116,7 @@ private static net.minecraft.core.HolderLookup.Provider registries() { CompoundTag root = chunk(foreign); LegacyWorldDataHook.prepareLegacyChunk(root); LegacyWorldDataHook.prepareLegacyEntities(root); - assertEquals(expected, root.getCompound("Level").getList("TileEntities", 10).getCompound(0)); + assertEquals(expected, root.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities").getCompoundOrEmpty(0)); } @Test public void currentVersionMachineDataIsNotRewritten() { @@ -132,7 +133,7 @@ private static net.minecraft.core.HolderLookup.Provider registries() { tiles.add(currentTile); CompoundTag root = new CompoundTag(); root.put("block_entities", tiles); - root.putInt("DataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + root.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); CompoundTag expected = root.copy(); LegacyWorldDataHook.prepareLegacyChunk(root); LegacyWorldDataHook.prepareLegacyEntities(root); @@ -147,10 +148,10 @@ private static net.minecraft.core.HolderLookup.Provider registries() { LegacyWorldDataHook.prepareLegacyChunk(root); LegacyWorldDataHook.prepareLegacyEntities(root); assertEquals(once, root); - CompoundTag item = root.getCompound("Level").getList("TileEntities", 10).getCompound(0) - .getList("Items", 10).getCompound(0); + CompoundTag item = root.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities").getCompoundOrEmpty(0) + .getListOrEmpty("Items").getCompoundOrEmpty(0); assertStack("minecraft:diamond_pickaxe", 1, item); - assertEquals(17, item.getCompound("components").getInt("minecraft:damage")); + assertEquals(17, item.getCompoundOrEmpty("components").getIntOr("minecraft:damage", 0)); } @Test public void namedBookEnchantmentsAreNotReinterpretedAsProtection() { @@ -163,7 +164,7 @@ private static net.minecraft.core.HolderLookup.Provider registries() { list.add(named); tag.put("StoredEnchantments", list); book.put("tag", tag); - CompoundTag actual = convertedTile(book).getList("Items", 10).getCompound(0); + CompoundTag actual = convertedTile(book).getListOrEmpty("Items").getCompoundOrEmpty(0); assertStack("minecraft:enchanted_book", 1, actual); assertEnchantment(actual, "minecraft:stored_enchantments", "steamadvantage:steam_capacity", 3); } @@ -181,13 +182,13 @@ private static net.minecraft.core.HolderLookup.Provider registries() { CompoundTag tag = new CompoundTag(); tag.put("BlockEntityTag", blockEntity); chest.put("tag", tag); - CompoundTag actual = convertedTile(chest).getList("Items", 10).getCompound(0); + CompoundTag actual = convertedTile(chest).getListOrEmpty("Items").getCompoundOrEmpty(0); assertStack("minecraft:chest", 1, actual); - ListTag fixed = actual.getCompound("components").getList("minecraft:container", 10); + ListTag fixed = actual.getCompoundOrEmpty("components").getListOrEmpty("minecraft:container"); assertEquals("Container slots: " + actual, 2, fixed.size()); CompoundTag pick = itemInContainerSlot(fixed, 0); assertStack("minecraft:diamond_pickaxe", 1, pick); - assertEquals(17, pick.getCompound("components").getInt("minecraft:damage")); + assertEquals(17, pick.getCompoundOrEmpty("components").getIntOr("minecraft:damage", 0)); assertStack("minecraft:red_wool", 7, itemInContainerSlot(fixed, 1)); } @@ -196,7 +197,7 @@ private static net.minecraft.core.HolderLookup.Provider registries() { CompoundTag tag = new CompoundTag(); tag.put("Private", stack("minecraft:wool", 14, 7)); foreign.put("tag", tag); - assertEquals(foreign, convertedTile(foreign).getList("Items", 10).getCompound(0)); + assertEquals(foreign, convertedTile(foreign).getListOrEmpty("Items").getCompoundOrEmpty(0)); } @Test public void nestedNamedEnchantmentsSurviveNativeContainerFixes() { @@ -211,9 +212,9 @@ private static net.minecraft.core.HolderLookup.Provider registries() { book.put("tag", tag); CompoundTag root = chunk(tile(carrier(book))); root.putInt("DataVersion", 1343); - CompoundTag actual = convert(root, 1343).getList("block_entities", 10) - .getCompound(0).getList("Items", 10).getCompound(0); - ListTag fixed = actual.getCompound("components").getList("minecraft:container", 10); + CompoundTag actual = convert(root, 1343).getListOrEmpty("block_entities") + .getCompoundOrEmpty(0).getListOrEmpty("Items").getCompoundOrEmpty(0); + ListTag fixed = actual.getCompoundOrEmpty("components").getListOrEmpty("minecraft:container"); assertEquals("Container slots: " + actual, 1, fixed.size()); CompoundTag fixedBook = itemInContainerSlot(fixed, 0); assertStack("minecraft:enchanted_book", 1, fixedBook); @@ -229,21 +230,23 @@ private static net.minecraft.core.HolderLookup.Provider registries() { foreign.put("tag", privateData); CompoundTag before = foreign.copy(); - CompoundTag chest = convertedTile(carrier(foreign)).getList("Items", 10).getCompound(0); + CompoundTag chest = convertedTile(carrier(foreign)).getListOrEmpty("Items").getCompoundOrEmpty(0); assertStack("minecraft:chest", 1, chest); - ListTag slots = chest.getCompound("components").getList("minecraft:container", 10); + ListTag slots = chest.getCompoundOrEmpty("components").getListOrEmpty("minecraft:container"); assertEquals("Container slots: " + chest, 1, slots.size()); CompoundTag actual = itemInContainerSlot(slots, 7); assertStack("other:component", 3, actual); assertEquals(before.get("Damage"), actual.get("Damage")); - assertEquals(before.getCompound("tag"), actual.getCompound("components").getCompound("minecraft:custom_data")); + assertEquals(before.getCompoundOrEmpty("tag"), actual.getCompoundOrEmpty("components").getCompoundOrEmpty("minecraft:custom_data")); } @Test public void registeredForeignToolKeepsWearInDirectAndNestedMachineSlots() { 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)); - net.minecraftforge.registries.ForgeRegistries.ITEMS.register(net.minecraft.resources.ResourceLocation.parse(id), tool); + 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); CompoundTag foreign = stack(id, 9, 1); CompoundTag payload = new CompoundTag(); payload.putInt("Fixture", 31); @@ -251,20 +254,26 @@ private static net.minecraft.core.HolderLookup.Provider registries() { CompoundTag original = foreign.copy(); var direct = zone.moddev.mc.basepower.util.ItemData.readStack(registries(), foreign); - CompoundTag chest = convertedTile(carrier(foreign)).getList("Items", 10).getCompound(0); - var carried = ItemStack.parseOptional(registries(), chest) + CompoundTag chest = convertedTile(carrier(foreign)).getListOrEmpty("Items").getCompoundOrEmpty(0); + var carried = parseStack(chest) .get(net.minecraft.core.component.DataComponents.CONTAINER).nonEmptyItems().iterator().next(); for (var loaded : List.of(direct, carried)) { assertSame(tool, loaded.getItem()); assertEquals(1, loaded.getCount()); assertEquals(9, loaded.getDamageValue()); assertEquals(payload, zone.moddev.mc.basepower.util.ItemData.customData(loaded)); - assertEquals(9, ItemStack.parseOptional(registries(), - (CompoundTag) loaded.save(registries())).getDamageValue()); + assertEquals(9, parseStack((CompoundTag) ItemStack.CODEC.encodeStart( + registries().createSerializationContext(net.minecraft.nbt.NbtOps.INSTANCE), loaded) + .getOrThrow()).getDamageValue()); } assertEquals(original, foreign); } + private static ItemStack parseStack(CompoundTag saved) { + return ItemStack.OPTIONAL_CODEC.parse(registries().createSerializationContext(net.minecraft.nbt.NbtOps.INSTANCE), + saved).getOrThrow(); + } + @Test public void positiveNativeContainerConversionsDoNotEmitErrors() { try (ErrorCapture capture = new ErrorCapture()) { nativeNestedContainerItemsAreFixedExactlyOnce(); @@ -278,16 +287,16 @@ private static net.minecraft.core.HolderLookup.Provider registries() { source.putIntArray("PrivateArray", new int[] {1, 2}); CompoundTag before = source.copy(); CompoundTag result = LegacyMachineItemDataFixes.upgradeTo704(source, 512, new EnvelopeFixer(root -> { - assertEquals(1, root.getAllKeys().size()); - assertEquals(1, root.getList("Inventory", 10).size()); - CompoundTag item = root.getList("Inventory", 10).getCompound(0); + assertEquals(1, root.keySet().size()); + assertEquals(1, root.getListOrEmpty("Inventory").size()); + CompoundTag item = root.getListOrEmpty("Inventory").getCompoundOrEmpty(0); assertEquals(before, item); - item.getIntArray("PrivateArray")[0] = 99; + item.getIntArray("PrivateArray").orElseThrow()[0] = 99; item.putString("NativeField", "retained"); })); assertEquals(before, source); - assertEquals("retained", result.getString("NativeField")); - assertEquals(99, result.getIntArray("PrivateArray")[0]); + assertEquals("retained", result.getStringOr("NativeField", "")); + assertEquals(99, result.getIntArray("PrivateArray").orElseThrow()[0]); result.putInt("AfterReturn", 1); assertEquals(before, source); } @@ -303,8 +312,8 @@ private static net.minecraft.core.HolderLookup.Provider registries() { } @Test public void native704EnvelopeRejectsNonSingletonInventories() { - rejectedEnvelope(root -> root.getList("Inventory", 10).remove(0)); - rejectedEnvelope(root -> root.getList("Inventory", 10).add(stack("minecraft:stone", 0, 1))); + rejectedEnvelope(root -> root.getListOrEmpty("Inventory").remove(0)); + rejectedEnvelope(root -> root.getListOrEmpty("Inventory").add(stack("minecraft:stone", 0, 1))); } @Test public void native704EnvelopeRejectsCountAndSlotChanges() { @@ -347,7 +356,7 @@ private static net.minecraft.core.HolderLookup.Provider registries() { } catch (IllegalStateException expected) { assertEquals(before, source); } } - private static CompoundTag envelopeItem(CompoundTag root) { return root.getList("Inventory", 10).getCompound(0); } + private static CompoundTag envelopeItem(CompoundTag root) { return root.getListOrEmpty("Inventory").getCompoundOrEmpty(0); } private static void rejectedEnvelope(Consumer mutation) { CompoundTag source = stack("minecraft:chest", 0, 1), before = source.copy(); @@ -400,34 +409,34 @@ private static CompoundTag carrier(CompoundTag item) { } private static void assertStack(String id, int count, CompoundTag item) { - assertEquals(id, item.getString("id")); - assertEquals("Stack count: " + item, count, item.getInt("count")); + assertEquals(id, item.getStringOr("id", "")); + assertEquals("Stack count: " + item, count, item.getIntOr("count", 0)); assertFalse("Legacy count remains in " + item, item.contains("Count")); } private static void assertEnchantment(CompoundTag item, String component, String name, int level) { - CompoundTag levels = item.getCompound("components").getCompound(component).getCompound("levels"); - assertEquals("Enchantment levels: " + item, java.util.Set.of(name), levels.getAllKeys()); - assertEquals(level, levels.getInt(name)); + CompoundTag levels = item.getCompoundOrEmpty("components").getCompoundOrEmpty(component); + assertEquals("Enchantment levels: " + item, java.util.Set.of(name), levels.keySet()); + assertEquals(level, levels.getIntOr(name, 0)); } private static CompoundTag itemInContainerSlot(ListTag slots, int slot) { for (int i = 0; i < slots.size(); i++) { - CompoundTag entry = slots.getCompound(i); - if (entry.contains("slot", 3) && entry.getInt("slot") == slot) return entry.getCompound("item"); + CompoundTag entry = slots.getCompoundOrEmpty(i); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(entry, "slot", 3) && entry.getIntOr("slot", 0) == slot) return entry.getCompoundOrEmpty("item"); } throw new AssertionError("Missing slot " + slot + " in " + slots); } private static CompoundTag convertedTile(CompoundTag item) { - return convert(chunk(tile(item)), 512).getList("block_entities", 10).getCompound(0); + return convert(chunk(tile(item)), 512).getListOrEmpty("block_entities").getCompoundOrEmpty(0); } private static CompoundTag convert(CompoundTag root, int version) { LegacyWorldDataHook.prepareLegacyChunk(root); LegacyWorldDataHook.prepareLegacyEntities(root); CompoundTag fixed = DataFixTypes.CHUNK.update(DataFixers.getDataFixer(), - root, version, SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + root, version, SharedConstants.getCurrentVersion().dataVersion().version()); return LegacyWorldDataHook.finalizeLegacyChunk(fixed); } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerDataFixesTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerDataFixesTest.java index 405ce22a..5057d97e 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerDataFixesTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerDataFixesTest.java @@ -26,7 +26,7 @@ public class LegacyPlayerDataFixesTest { for (String inventory : new String[] {"Inventory", "EnderItems"}) { for (String name : new String[] {"minecraft:efficiency", "steamadvantage:steam_capacity"}) { CompoundTag book = enchanted("minecraft:enchanted_book", "StoredEnchantments", false); - assertTrue(LegacyWorldDataHook.migrateLegacyEnchantments(book.getCompound("tag"), + assertTrue(LegacyWorldDataHook.migrateLegacyEnchantments(book.getCompoundOrEmpty("tag"), Collections.singletonMap(32, name))); CompoundTag source = player(inventory, book), before = source.copy(); CompoundTag result = convert(source, 512); @@ -45,7 +45,7 @@ public class LegacyPlayerDataFixesTest { CompoundTag actual = item(result, "Inventory", 0); assertStack("minecraft:diamond_pickaxe", 1, actual); assertComponentEnchantment(actual, "minecraft:enchantments", "steamadvantage:steam_capacity", 3); - assertEquals(17, actual.getCompound("components").getInt("minecraft:damage")); + assertEquals(17, actual.getCompoundOrEmpty("components").getIntOr("minecraft:damage", 0)); } } @@ -89,11 +89,11 @@ public class LegacyPlayerDataFixesTest { CompoundTag fixedChest = item(result, "Inventory", 0); assertStack("minecraft:chest", 1, fixedChest); assertSlot(fixedChest, 0); - ListTag fixed = fixedChest.getCompound("components").getList("minecraft:container", 10); + ListTag fixed = fixedChest.getCompoundOrEmpty("components").getListOrEmpty("minecraft:container"); assertEquals("Container slots: " + fixedChest, 4, fixed.size()); CompoundTag pick = itemInContainerSlot(fixed, 0); assertStack("minecraft:diamond_pickaxe", 1, pick); - assertEquals(17, pick.getCompound("components").getInt("minecraft:damage")); + assertEquals(17, pick.getCompoundOrEmpty("components").getIntOr("minecraft:damage", 0)); assertComponentEnchantment(pick, "minecraft:enchantments", "steamadvantage:steam_capacity", 3); assertStack("minecraft:red_wool", 7, itemInContainerSlot(fixed, 1)); CompoundTag fixedBook = itemInContainerSlot(fixed, 2); @@ -101,27 +101,27 @@ public class LegacyPlayerDataFixesTest { assertComponentEnchantment(fixedBook, "minecraft:stored_enchantments", "steamadvantage:steam_capacity", 3); CompoundTag fixedForeign = itemInContainerSlot(fixed, 7); assertStack("other:component", 3, fixedForeign); - assertEquals(privateData, fixedForeign.getCompound("components").getCompound("minecraft:custom_data")); - int current = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + assertEquals(privateData, fixedForeign.getCompoundOrEmpty("components").getCompoundOrEmpty("minecraft:custom_data")); + int current = SharedConstants.getCurrentVersion().dataVersion().version(); assertEquals(result, convert(result, current)); assertEquals(before, source); } @Test public void foreignItemPayloadIsNotRestoredOverNativeChanges() { CompoundTag foreign = enchanted("other:component", "StoredEnchantments", true); - foreign.getCompound("tag").putInt("Private", 19); + foreign.getCompoundOrEmpty("tag").putInt("Private", 19); CompoundTag source = player("Inventory", foreign); CompoundTag before = source.copy(); CompoundTag actual = item(convert(source, 512), "Inventory", 0); assertStack("other:component", 1, actual); assertSlot(actual, 0); assertFalse("Native damage removal must not be undone by a whole-stack overlay", actual.contains("Damage")); - CompoundTag components = actual.getCompound("components"); + CompoundTag components = actual.getCompoundOrEmpty("components"); assertFalse("Legacy subtype is not vanilla item wear", components.contains("minecraft:damage")); assertFalse("The non-book's private data must not become a stored enchantment", components.contains("minecraft:stored_enchantments")); - assertEquals(item(before, "Inventory", 0).getCompound("tag"), - components.getCompound("minecraft:custom_data")); + assertEquals(item(before, "Inventory", 0).getCompoundOrEmpty("tag"), + components.getCompoundOrEmpty("minecraft:custom_data")); assertEquals(before, source); } @@ -132,14 +132,14 @@ public class LegacyPlayerDataFixesTest { book.putString("id", "other:native_result"); enchantment(book, "StoredEnchantments").putString("id", "minecraft:protection"); enchantment(book, "StoredEnchantments").putShort("lvl", (short) 5); - book.getCompound("tag").putInt("NativeField", 42); + book.getCompoundOrEmpty("tag").putInt("NativeField", 42); }, true); CompoundTag result = LegacyPlayerDataFixes.update(fixer, DataFixTypes.PLAYER, source, 704); CompoundTag book = item(result, "Inventory", 0); - assertEquals("other:native_result", book.getString("id")); - assertEquals(42, book.getCompound("tag").getInt("NativeField")); - assertEquals(5, enchantment(book, "StoredEnchantments").getInt("lvl")); - assertEquals("steamadvantage:steam_capacity", enchantment(book, "StoredEnchantments").getString("id")); + assertEquals("other:native_result", book.getStringOr("id", "")); + assertEquals(42, book.getCompoundOrEmpty("tag").getIntOr("NativeField", 0)); + assertEquals(5, enchantment(book, "StoredEnchantments").getIntOr("lvl", 0)); + assertEquals("steamadvantage:steam_capacity", enchantment(book, "StoredEnchantments").getStringOr("id", "")); } @Test public void sourceIsDetachedEvenWhenFixerMutatesNestedTagsAndArrays() { @@ -147,8 +147,8 @@ public class LegacyPlayerDataFixesTest { source.putIntArray("PrivateArray", new int[] {1, 2}); CompoundTag before = source.copy(); RecordingFixer fixer = new RecordingFixer(root -> { - root.getIntArray("PrivateArray")[0] = 99; - item(root, "Inventory", 0).getCompound("tag").putInt("Native", 1); + root.getIntArray("PrivateArray").orElseThrow()[0] = 99; + item(root, "Inventory", 0).getCompoundOrEmpty("tag").putInt("Native", 1); root.putString("Native", "changed"); }); CompoundTag result = LegacyPlayerDataFixes.update(fixer, DataFixTypes.PLAYER, source, 512); @@ -159,7 +159,7 @@ public class LegacyPlayerDataFixesTest { } @Test public void usesThePassedFixerAndNativePlayerTypeWithExplicitSchemaBoundaries() { - int current = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + int current = SharedConstants.getCurrentVersion().dataVersion().version(); for (int source : new int[] {-1, 512, 703, 704, 1343, 2230, 3464, 3465, 3466}) { RecordingFixer fixer = new RecordingFixer(root -> {}); LegacyPlayerDataFixes.update(fixer, DataFixTypes.PLAYER, player("Inventory", stack("minecraft:stone", 0)), source); @@ -173,7 +173,7 @@ public class LegacyPlayerDataFixesTest { @Test public void currentOrNewerDataIsCopiedWithoutOldVersionDfu() { CompoundTag modern = convert(player("Inventory", stack("minecraft:diamond_pickaxe", 17)), 512); - for (int version : new int[] {SharedConstants.getCurrentVersion().getDataVersion().getVersion(), Integer.MAX_VALUE}) { + for (int version : new int[] {SharedConstants.getCurrentVersion().dataVersion().version(), Integer.MAX_VALUE}) { CompoundTag source = modern.copy(); source.putInt("DataVersion", version); RecordingFixer fixer = new RecordingFixer(root -> fail("Current data must not be reinterpreted")); @@ -194,8 +194,8 @@ public class LegacyPlayerDataFixesTest { } @Test public void changedInventoryCardinalityFailsClosed() { - rejected(root -> root.getList("Inventory", 10).remove(0)); - rejected(root -> root.getList("Inventory", 10).add(stack("minecraft:stone", 0))); + rejected(root -> root.getListOrEmpty("Inventory").remove(0)); + rejected(root -> root.getListOrEmpty("Inventory").add(stack("minecraft:stone", 0))); } @Test public void addingAnItemToAnEmptyInventoryFailsClosed() { @@ -203,10 +203,10 @@ public class LegacyPlayerDataFixesTest { CompoundTag source = new CompoundTag(); source.put(inventory, new ListTag()); try { - LegacyPlayerDataFixes.update(new RecordingFixer(root -> root.getList(inventory, 10) + LegacyPlayerDataFixes.update(new RecordingFixer(root -> root.getListOrEmpty(inventory) .add(stack("minecraft:stone", 0))), DataFixTypes.PLAYER, source, 704); fail("An empty inventory must not acquire an unpaired item"); - } catch (IllegalStateException expected) { assertTrue(source.getList(inventory, 10).isEmpty()); } + } catch (IllegalStateException expected) { assertTrue(source.getListOrEmpty(inventory).isEmpty()); } } } @@ -227,7 +227,7 @@ public class LegacyPlayerDataFixesTest { } @Test public void changedNamedEnchantmentCardinalityFailsClosed() { - rejected(root -> item(root, "Inventory", 0).getCompound("tag").getList("StoredEnchantments", 10).remove(0)); + rejected(root -> item(root, "Inventory", 0).getCompoundOrEmpty("tag").getListOrEmpty("StoredEnchantments").remove(0)); } @Test public void wrongNativeTypeIsRejected() { @@ -278,34 +278,34 @@ static CompoundTag enchanted(String id, String key, boolean named) { entry.putShort("lvl", (short) 3); ListTag enchantments = new ListTag(); enchantments.add(entry); - item.getCompound("tag").put(key, enchantments); + item.getCompoundOrEmpty("tag").put(key, enchantments); return item; } - static CompoundTag item(CompoundTag root, String inventory, int index) { return root.getList(inventory, 10).getCompound(index); } - static CompoundTag enchantment(CompoundTag item, String key) { return item.getCompound("tag").getList(key, 10).getCompound(0); } + static CompoundTag item(CompoundTag root, String inventory, int index) { return root.getListOrEmpty(inventory).getCompoundOrEmpty(index); } + static CompoundTag enchantment(CompoundTag item, String key) { return item.getCompoundOrEmpty("tag").getListOrEmpty(key).getCompoundOrEmpty(0); } private static void assertStack(String id, int count, CompoundTag item) { - assertEquals(id, item.getString("id")); - assertEquals("Stack count: " + item, count, item.getInt("count")); + assertEquals(id, item.getStringOr("id", "")); + assertEquals("Stack count: " + item, count, item.getIntOr("count", 0)); assertFalse("Legacy count remains in " + item, item.contains("Count")); } private static void assertSlot(CompoundTag item, int slot) { - assertTrue("Missing inventory slot in " + item, item.contains("Slot", 1)); - assertEquals(slot, item.getByte("Slot")); + assertTrue("Missing inventory slot in " + item, zone.moddev.mc.basepower.util.NbtCompat.containsTag(item, "Slot", 1)); + assertEquals(slot, item.getByteOr("Slot", (byte)0)); } private static void assertComponentEnchantment(CompoundTag item, String component, String name, int level) { - CompoundTag levels = item.getCompound("components").getCompound(component).getCompound("levels"); - assertEquals("Enchantment levels: " + item, Set.of(name), levels.getAllKeys()); - assertEquals(level, levels.getInt(name)); + CompoundTag levels = item.getCompoundOrEmpty("components").getCompoundOrEmpty(component); + assertEquals("Enchantment levels: " + item, Set.of(name), levels.keySet()); + assertEquals(level, levels.getIntOr(name, 0)); } private static CompoundTag itemInContainerSlot(ListTag slots, int slot) { for (int i = 0; i < slots.size(); i++) { - CompoundTag entry = slots.getCompound(i); - if (entry.contains("slot", 3) && entry.getInt("slot") == slot) return entry.getCompound("item"); + CompoundTag entry = slots.getCompoundOrEmpty(i); + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(entry, "slot", 3) && entry.getIntOr("slot", 0) == slot) return entry.getCompoundOrEmpty("item"); } throw new AssertionError("Missing slot " + slot + " in " + slots); } 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 d97d7d1e..bcb5eb1d 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerTransformerTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyPlayerTransformerTest.java @@ -4,30 +4,27 @@ import com.mojang.datafixers.DataFixer; import com.mojang.datafixers.schemas.Schema; import com.mojang.serialization.Dynamic; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Set; import net.minecraft.SharedConstants; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; import net.minecraft.nbt.NbtAccounter; import net.minecraft.nbt.NbtIo; -import net.minecraft.nbt.Tag; +import net.minecraft.nbt.NbtOps; import net.minecraft.server.Bootstrap; import net.minecraft.util.datafix.DataFixers; +import net.minecraft.util.datafix.DataFixTypes; import net.minecraft.util.datafix.fixes.References; -import net.minecraft.world.level.validation.DirectoryValidator; -import net.minecraftforge.fml.ModList; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Opcodes; -import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.InsnList; @@ -35,195 +32,203 @@ import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; 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. */ public class LegacyPlayerTransformerTest { - private static final String NATIVE = "net/minecraft/util/datafix/DataFixTypes"; - private static final String WRAPPER = "zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes"; - private static final String STORAGE = "net/minecraft/world/level/storage/"; - private static final String TAG = "Lnet/minecraft/nbt/CompoundTag;"; - private static final String DYNAMIC = "Lcom/mojang/serialization/Dynamic;"; - private static final String FIXER = "Lcom/mojang/datafixers/DataFixer;"; - private static final String READER_DESC = "(Ljava/nio/file/Path;" + FIXER + ")" + DYNAMIC; - private static final PlayerHook LOOSE = new PlayerHook("PlayerDataStorage", "legacy_player_file", TAG, "updateNativePlayer"); - private static final PlayerHook EMBEDDED = new PlayerHook("LevelStorageSource", "legacy_world_info", DYNAMIC, - "updateNativeEmbeddedPlayer"); - private static final List TARGETS = List.of(LOOSE, EMBEDDED); - @Rule public final TemporaryFolder temp = new TemporaryFolder(); - @Test public void exactNativeLoosePlayerLoadAndEmbeddedUpdateArePatched() throws Exception { - for (PlayerHook target : TARGETS) { - ClassNode node = target.read(); - MethodNode containing = nativePlayerMethod(node, target); - assertTrue(containing.name, containing.name.startsWith(target == LOOSE ? "lambda$load$" : "lambda$readLevelDataTagFixed$")); - int otherNativeUpdates = target.nativeCalls(node).size() - 1; - target.transform(node); - assertEquals(otherNativeUpdates, target.nativeCalls(node).size()); - assertEquals(1, LegacyTransformerOrderTest.calls(node, WRAPPER, target.wrapperName(), target.wrapperDesc())); - assertEquals(1, LegacyTransformerOrderTest.calls(containing, WRAPPER, target.wrapperName(), target.wrapperDesc())); + private static Binding loose() throws Exception { + return binding("basepower", "PlayerDataStorageMixin", "preservePlayer"); + } + + private static Binding embedded() throws Exception { + return binding("basepower", "LevelStorageSourceMixin", "preserveEmbeddedPlayer"); + } + + private static List targets() throws Exception { return List.of(loose(), embedded()); } + + @Test public void exactNativeLoosePlayerLoadAndEmbeddedUpdateAreRedirected() throws Exception { + for (Binding target : targets()) { + assertEquals("*", target.selector()); + assertTrue(target.injector().desc.endsWith("/Redirect;")); + target.assertAt("INVOKE"); + String payload = target.target().name.endsWith("PlayerDataStorage") ? TAG : DYNAMIC; + 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"; + 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())); } - @Test public void transformedNativeEmbeddedReaderExecutesAndDoesNotReconvertCurrentPlayer() throws Exception { + @Test public void nativeEmbeddedRedirectExecutesAndDoesNotReconvertCurrentPlayer() throws Exception { SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); - ClassNode node = EMBEDDED.read(); - EMBEDDED.transform(node); - byte[] transformed = bytes(node); - String name = node.name.replace('/', '.'); - Class storage = new ClassLoader(getClass().getClassLoader()) { - Class define() { return defineClass(name, transformed, 0, transformed.length); } - - @Override protected Class loadClass(String requested, boolean resolve) throws ClassNotFoundException { - // Keep the native access object's nest in the transformed reader's loader. - if (!requested.startsWith(name + "$")) return super.loadClass(requested, resolve); - synchronized (getClassLoadingLock(requested)) { - Class type = findLoadedClass(requested); - if (type == null) { - try (InputStream input = LegacyTransformerOrderTest.resource("/" + requested.replace('.', '/') + ".class")) { - byte[] data = input.readAllBytes(); - type = defineClass(requested, data, 0, data.length); - } catch (IOException e) { - throw new ClassNotFoundException(requested, e); - } - } - if (resolve) resolveClass(type); - return type; - } - } - }.define(); - Path saves = temp.newFolder("saves").toPath(); RecordingFixer fixer = new RecordingFixer(DataFixers.getDataFixer()); - Object validator = storage.getMethod("parseValidator", Path.class).invoke(null, saves.resolve("allowed_symlinks.txt")); - Object source = storage.getConstructor(Path.class, Path.class, DirectoryValidator.class, DataFixer.class) - .newInstance(saves, saves.resolveSibling("backups"), validator, fixer); - - // An empty native ModList supplies the metadata hook's normal loader context in plain JUnit. - ModList previous = ModList.get(); - if (previous == null) ModList.of(List.of(), List.of()); - try { - int current = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); - for (int old : new int[] {512, 1343}) { - String world = "legacy-" + old; - Path levelDat = saves.resolve(world).resolve("level.dat"); - CompoundTag original = levelData(old, legacyPlayer()); - CompoundTag migrated; - try (AutoCloseable access = openWorld(storage, source, world)) { - NbtIo.writeCompressed(original, levelDat); - fixer.playerCalls.clear(); - migrated = readPlayer(access); - assertPlayerContents(migrated); - int boundary = 3465; // Minecraft 1.20.1, before item components. - assertEquals(old < 704 ? List.of(old + "->704", "704->" + boundary, boundary + "->" + current) - : List.of(old + "->" + boundary, boundary + "->" + current), fixer.playerCalls); - assertEquals(original, NbtIo.readCompressed(levelDat, NbtAccounter.unlimitedHeap())); - } - CompoundTag saved = levelData(current, migrated); - NbtIo.writeCompressed(saved, levelDat); - try (AutoCloseable access = openWorld(storage, source, world)) { - fixer.playerCalls.clear(); - assertEquals(migrated, readPlayer(access)); - assertTrue("Current player data must not re-enter legacy DFU", fixer.playerCalls.isEmpty()); - assertEquals(saved, NbtIo.readCompressed(levelDat, NbtAccounter.unlimitedHeap())); - } - } - } finally { - if (previous == null) { - Field instance = ModList.class.getDeclaredField("INSTANCE"); - instance.setAccessible(true); - instance.set(null, null); - } + Method redirect = redirect(); + int current = SharedConstants.getCurrentVersion().dataVersion().version(); + for (int old : new int[] {512, 1343}) { + Path file = temp.newFolder("legacy-" + old).toPath().resolve("level.dat"); + CompoundTag original = levelData(old, legacyPlayer()); + NbtIo.writeCompressed(original, file); + CompoundTag read = NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap()); + CompoundTag migrated = invoke(redirect, DataFixTypes.PLAYER, fixer, + read.getCompoundOrEmpty("Data").getCompoundOrEmpty("Player"), old); + assertPlayerContents(migrated); + int boundary = 3465; + assertEquals(old < 704 ? List.of(old + "->704", "704->" + boundary, boundary + "->" + current) + : List.of(old + "->" + boundary, boundary + "->" + current), fixer.playerCalls); + assertEquals(original, NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap())); + CompoundTag saved = levelData(current, migrated); + NbtIo.writeCompressed(saved, file); + fixer.playerCalls.clear(); + assertEquals(migrated, invoke(redirect, DataFixTypes.PLAYER, fixer, migrated, current)); + assertTrue("Current player must not re-enter legacy DFU", fixer.playerCalls.isEmpty()); + assertEquals(saved, NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap())); } + 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)); + assertTrue("Unrelated types cannot enter the player wrapper", fixer.playerCalls.isEmpty()); } - @Test public void renamedPlayerCallAndNativeLambdaNamesDoNotAffectMatching() throws Exception { - for (PlayerHook target : TARGETS) { - ClassNode node = target.read(); - MethodNode method = nativePlayerMethod(node, target); - nativePlayerCall(node, target).name = "renamedNativePlayerUpdate"; - method.name = "renamedPlayerLambda"; - target.transform(node); - assertEquals(1, LegacyTransformerOrderTest.calls(method, WRAPPER, target.wrapperName(), target.wrapperDesc())); + @Test public void renamedNativeLambdaNamesDoNotAffectWildcardMatching() throws Exception { + for (Binding target : targets()) { + ClassNode nativeClass = read(target.target().name); + target.assertOnePlayerSite(nativeClass).name = "renamedPlayerLambda"; + assertEquals("renamedPlayerLambda", target.assertOnePlayerSite(nativeClass).name); } } - @Test public void absentCallFailsClosedWithoutPatchingOtherOwners() throws Exception { - for (PlayerHook target : TARGETS) { - ClassNode node = target.read(); - nativePlayerCall(node, target).owner = "other/UnrelatedNBTUtil"; - rejectedUnchanged(node, target); + @Test public void absentCallFailsClosedWithoutAcceptingOtherOwnersOrNames() throws Exception { + for (Binding 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"; + else call.name = "renamedNativePlayerUpdate"; + rejectedUnchanged(target, nativeClass); } } - @Test public void duplicateCallsFailClosedBeforeEitherIsRewritten() throws Exception { - for (PlayerHook target : TARGETS) { - ClassNode node = target.read(); - MethodNode method = nativePlayerMethod(node, target); - MethodInsnNode call = nativePlayerCall(node, target); - method.instructions.insertBefore(call, new MethodInsnNode(call.getOpcode(), call.owner, call.name, call.desc, call.itf)); - rejectedUnchanged(node, target); + @Test public void duplicateCallsInOneReaderFailClosed() throws Exception { + for (Binding target : targets()) { + ClassNode nativeClass = read(target.target().name); + MethodNode method = target.assertOnePlayerSite(nativeClass); + MethodNode copy = copy(method, method.name); + for (var insn : method.instructions.toArray()) { + if (insn.getOpcode() == Opcodes.ARETURN) method.instructions.set(insn, new InsnNode(Opcodes.POP)); + } + method.instructions.add(copy.instructions); + assertEquals(2, target.playerMethods(nativeClass).size()); + rejectedUnchanged(target, nativeClass); } } - @Test public void duplicateEmbeddedPlayerSitesFailClosedBeforeAnyReaderIsRewritten() throws Exception { - ClassNode node = EMBEDDED.read(); - MethodNode original = nativePlayerMethod(node, EMBEDDED); - MethodNode extra = new MethodNode(original.access, "anotherPlayerReader", original.desc, original.signature, null); - original.accept(extra); - node.methods.add(extra); - rejectedUnchanged(node, EMBEDDED); + @Test public void duplicateEmbeddedPlayerReadersFailClosed() throws Exception { + Binding 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 (PlayerHook target : TARGETS) { - ClassNode node = target.read(); - nativePlayerCall(node, target).desc = target.desc().replace(";I)", ";II)"); - rejectedUnchanged(node, target); + for (Binding target : targets()) { + ClassNode nativeClass = read(target.target().name); + MethodInsnNode call = playerCall(target, nativeClass); + call.desc = call.desc.replace(";I)", ";II)"); + rejectedUnchanged(target, nativeClass); } } - @Test public void embeddedScanCannotBorrowAnotherDataFixTypeAfterPlayer() throws Exception { - ClassNode node = EMBEDDED.read(); - MethodNode method = nativePlayerMethod(node, EMBEDDED); - AbstractInsnNode playerType = Arrays.stream(method.instructions.toArray()) - .filter(LegacyPlayerTransformerTest::loadsPlayerType).findFirst().orElseThrow(); - InsnList changeType = new InsnList(); - changeType.add(new InsnNode(Opcodes.POP)); - changeType.add(new FieldInsnNode(Opcodes.GETSTATIC, NATIVE, "LEVEL", "L" + NATIVE + ";")); - method.instructions.insert(playerType, changeType); - rejectedUnchanged(node, EMBEDDED); + @Test public void embeddedReceiverCannotBorrowANearbyPlayerConstant() throws Exception { + Binding 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(); + InsnList change = new InsnList(); + change.add(new InsnNode(Opcodes.POP)); + change.add(new FieldInsnNode(Opcodes.GETSTATIC, FIX_TYPES, "LEVEL", "L" + FIX_TYPES + ";")); + method.instructions.insert(player, change); + 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 publicEmbeddedReadStillDelegatesToThePatchedNativeReader() throws Exception { - ClassNode node = EMBEDDED.read(); - EMBEDDED.transform(node); - ClassNode access = LegacyTransformerOrderTest.nativeClass(STORAGE + "LevelStorageSource$LevelStorageAccess"); - assertEquals(1, LegacyTransformerOrderTest.calls(access, node.name, "readLevelDataTagFixed", READER_DESC)); - assertEquals(1, LegacyTransformerOrderTest.calls(node, WRAPPER, EMBEDDED.wrapperName(), EMBEDDED.wrapperDesc())); + 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.setAccessible(true); + return method; } - private static MethodNode nativePlayerMethod(ClassNode node, PlayerHook target) { - List methods = node.methods.stream().filter(method -> - target.nativeCalls(method).size() == 1 && Arrays.stream(method.instructions.toArray()).anyMatch( - LegacyPlayerTransformerTest::loadsPlayerType)).toList(); - assertEquals("Expected one Forge 52 PLAYER reader in " + node.name, 1, methods.size()); - return methods.getFirst(); + 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); + assertEquals("Native boundary cannot mutate its caller", before, input); + return (CompoundTag) result.getValue(); } - private static MethodInsnNode nativePlayerCall(ClassNode node, PlayerHook target) { - return target.nativeCalls(nativePlayerMethod(node, target)).getFirst(); + private static MethodInsnNode playerCall(Binding binding, ClassNode nativeClass) throws Exception { + return calls(binding.assertOnePlayerSite(nativeClass)).stream().filter(binding::matches).findFirst().orElseThrow(); } - private static boolean loadsPlayerType(AbstractInsnNode instruction) { - return instruction instanceof FieldInsnNode field && field.getOpcode() == Opcodes.GETSTATIC - && field.owner.equals(NATIVE) && field.name.equals("PLAYER"); + private static MethodNode copy(MethodNode source, String name) { + MethodNode result = new MethodNode(source.access, name, source.desc, source.signature, null); + source.accept(result); + return result; } - private static void rejectedUnchanged(ClassNode node, PlayerHook target) throws Exception { - byte[] before = bytes(node); - LegacyTransformerOrderTest.expectRejection(() -> target.transform(node), - target == LOOSE ? "exactly one native player" : "embedded player"); - assertArrayEquals("Rejection must precede any bytecode edit", before, bytes(node)); + private static void rejectedUnchanged(Binding target, ClassNode nativeClass) throws Exception { + byte[] before = bytes(nativeClass); + assertThrows("Unsupported native PLAYER ABI must fail the contract", AssertionError.class, + () -> target.assertOnePlayerSite(nativeClass)); + assertArrayEquals("Contract checks cannot mutate native classes", before, bytes(nativeClass)); } private static byte[] bytes(ClassNode node) { @@ -233,16 +238,16 @@ private static byte[] bytes(ClassNode node) { } private static CompoundTag levelData(int version, CompoundTag player) { - CompoundTag root = new CompoundTag(); CompoundTag data = new CompoundTag(); data.putInt("DataVersion", version); data.put("Player", player.copy()); + CompoundTag root = new CompoundTag(); root.put("Data", data); return root; } private static CompoundTag legacyPlayer() { - CompoundTag book = legacyStack("minecraft:enchanted_book", 4, 1, 0); + CompoundTag book = stack("minecraft:enchanted_book", 4, 1, 0); CompoundTag enchantment = new CompoundTag(); enchantment.putString("id", "steamadvantage:steam_capacity"); enchantment.putShort("lvl", (short) 3); @@ -253,13 +258,13 @@ private static CompoundTag legacyPlayer() { book.put("tag", tag); ListTag inventory = new ListTag(); inventory.add(book); - inventory.add(legacyStack("minecraft:wool", 7, 5, 14)); + inventory.add(stack("minecraft:wool", 7, 5, 14)); CompoundTag player = new CompoundTag(); player.put("Inventory", inventory); return player; } - private static CompoundTag legacyStack(String id, int slot, int count, int damage) { + private static CompoundTag stack(String id, int slot, int count, int damage) { CompoundTag stack = new CompoundTag(); stack.putString("id", id); stack.putByte("Slot", (byte) slot); @@ -268,65 +273,30 @@ private static CompoundTag legacyStack(String id, int slot, int count, int damag return stack; } - private static CompoundTag readPlayer(AutoCloseable access) throws Exception { - Dynamic fixed = (Dynamic) access.getClass().getMethod("getDataTag").invoke(access); - return ((CompoundTag) fixed.getValue()).getCompound("Player"); - } - - private static AutoCloseable openWorld(Class storage, Object source, String world) throws Exception { - return (AutoCloseable) storage.getMethod("createAccess", String.class).invoke(source, world); - } - private static void assertPlayerContents(CompoundTag player) { - ListTag items = player.getList("Inventory", Tag.TAG_COMPOUND); + ListTag items = player.getListOrEmpty("Inventory"); assertEquals(2, items.size()); - CompoundTag book = items.getCompound(0); - assertEquals("minecraft:enchanted_book", book.getString("id")); - assertEquals(4, book.getByte("Slot")); - assertEquals(1, book.getInt("count")); - CompoundTag levels = book.getCompound("components").getCompound("minecraft:stored_enchantments").getCompound("levels"); - assertEquals(3, levels.getInt("steamadvantage:steam_capacity")); - CompoundTag wool = items.getCompound(1); - assertEquals("minecraft:red_wool", wool.getString("id")); - assertEquals(7, wool.getByte("Slot")); - assertEquals(5, wool.getInt("count")); - } - - private record PlayerHook(String nativeName, String coremodName, String payload, String wrapperName) { - String desc() { return "(" + FIXER + payload + "I)" + payload; } - String wrapperDesc() { return "(L" + NATIVE + ";" + desc().substring(1); } - - ClassNode read() throws IOException { - return LegacyTransformerOrderTest.nativeClass(STORAGE + nativeName); - } - - void transform(ClassNode node) throws Exception { - LegacyTransformerOrderTest.transform(node, coremodName, false); - } - - List nativeCalls(MethodNode method) { - return Arrays.stream(method.instructions.toArray()) - .filter(insn -> insn instanceof MethodInsnNode call && call.getOpcode() == Opcodes.INVOKEVIRTUAL - && call.owner.equals(NATIVE) && call.desc.equals(desc())) - .map(MethodInsnNode.class::cast).toList(); - } - - List nativeCalls(ClassNode node) { - return node.methods.stream().flatMap(method -> nativeCalls(method).stream()).toList(); - } + CompoundTag book = items.getCompoundOrEmpty(0); + assertEquals("minecraft:enchanted_book", book.getStringOr("id", "")); + assertEquals(4, book.getByteOr("Slot", (byte) 0)); + assertEquals(1, book.getIntOr("count", 0)); + CompoundTag levels = book.getCompoundOrEmpty("components").getCompoundOrEmpty("minecraft:stored_enchantments"); + assertEquals(Set.of("steamadvantage:steam_capacity"), levels.keySet()); + assertEquals(3, levels.getIntOr("steamadvantage:steam_capacity", 0)); + CompoundTag wool = items.getCompoundOrEmpty(1); + assertEquals("minecraft:red_wool", wool.getStringOr("id", "")); + assertEquals(7, wool.getByteOr("Slot", (byte) 0)); + assertEquals(5, wool.getIntOr("count", 0)); } private static final class RecordingFixer implements DataFixer { private final DataFixer nativeFixer; private final List playerCalls = new ArrayList<>(); - RecordingFixer(DataFixer nativeFixer) { this.nativeFixer = nativeFixer; } - @Override public Dynamic update(DSL.TypeReference type, Dynamic input, int from, int to) { if (type == References.PLAYER) playerCalls.add(from + "->" + to); return nativeFixer.update(type, input, from, to); } - @Override public Schema getSchema(int version) { return nativeFixer.getSchema(version); } } } 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 a21c421b..3ab15ce1 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyRegistryArchiveTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyRegistryArchiveTest.java @@ -6,7 +6,7 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; import net.minecraft.nbt.NbtIo; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -34,7 +34,7 @@ public class LegacyRegistryArchiveTest { assertNotNull(retained); assertNotEquals(level.toFile(), retained); var archive = NbtIo.readCompressed(retained.toPath(), net.minecraft.nbt.NbtAccounter.unlimitedHeap()); - assertEquals(source.getCompound("FML"), archive.getCompound("FML")); + assertEquals(source.getCompoundOrEmpty("FML"), archive.getCompoundOrEmpty("FML")); assertFalse(archive.contains("Data")); LegacyWorldDataHook.preserveLegacyRegistry(level.toFile(), nativeSave); assertEquals(archive, NbtIo.readCompressed(retained.toPath(), net.minecraft.nbt.NbtAccounter.unlimitedHeap())); @@ -49,13 +49,13 @@ public class LegacyRegistryArchiveTest { Files.write(region, header); try { assertEquals(1, LegacyWorldDataHook.indexLegacyChunks(world.toFile())); - var nether = ResourceLocation.parse("the_nether"); + var nether = Identifier.parse("the_nether"); assertTrue(LegacyWorldDataHook.isProtectedChunk(nether, new BlockPos(0, 0, 0))); assertTrue(LegacyWorldDataHook.isProtectedChunk(nether, new BlockPos(0, 255, 0))); assertFalse(LegacyWorldDataHook.isProtectedChunk(nether, new BlockPos(0, -1, 0))); assertFalse(LegacyWorldDataHook.isProtectedChunk(nether, new BlockPos(0, 256, 0))); assertFalse(LegacyWorldDataHook.isProtectedChunk(nether, new BlockPos(16, 64, 0))); - assertFalse(LegacyWorldDataHook.isProtectedChunk(ResourceLocation.parse("overworld"), new BlockPos(0, 64, 0))); + assertFalse(LegacyWorldDataHook.isProtectedChunk(Identifier.parse("overworld"), new BlockPos(0, 64, 0))); } finally { LegacyWorldDataHook.indexLegacyChunks(temporary.newFolder()); } 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 b03f1c23..5decf0b4 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyTransformerOrderTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyTransformerOrderTest.java @@ -1,233 +1,187 @@ package zone.moddev.mc.basepower.migration; -import java.io.IOException; -import java.io.InputStream; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import javax.script.ScriptEngine; -import javax.script.ScriptEngineManager; -import javax.script.ScriptException; +import java.util.Set; import org.junit.Test; -import org.objectweb.asm.ClassReader; import org.objectweb.asm.Opcodes; -import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.JumpInsnNode; -import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.MethodNode; import static org.junit.Assert.*; +import static zone.moddev.mc.basepower.migration.NativeMigrationMixinContract.*; -/** Applies both real migration scripts to the exact native Forge 52 classes. */ +/** Strict native Mixin contracts. No hand-written transformer is substituted for the production injectors. */ public class LegacyTransformerOrderTest { - private static final String WORLD = "net/minecraft/world/level/storage/LevelStorageSource"; - private static final String CHUNK = "net/minecraft/world/level/chunk/storage/ChunkStorage"; - private static final String ENTITIES = "net/minecraft/world/level/chunk/storage/EntityStorage"; - private static final String REGION = "net/minecraft/world/level/chunk/storage/SimpleRegionStorage"; - private static final String WORLDGEN = "net/minecraft/server/level/WorldGenRegion"; private static final String POWER = "zone/moddev/mc/basepower/migration/LegacyWorldDataHook"; private static final String METALS = "zone/moddev/mc/basemetals/migration/LegacyWorldDataHook"; - private static final String METADATA = "zone/moddev/mc/basepower/migration/LegacyWorldMetadata"; - private static final String PLAYER = "zone/moddev/mc/basepower/migration/LegacyPlayerDataFixes"; - private static final String NBT = "Lnet/minecraft/nbt/CompoundTag;"; - private static final String DYNAMIC = "Lcom/mojang/serialization/Dynamic;"; - private static final String FIXER = "Lcom/mojang/datafixers/DataFixer;"; - private static final String WORLD_READ = "(Ljava/nio/file/Path;" + FIXER + ")" + DYNAMIC; - private static final String CHUNK_UPGRADE = "(Lnet/minecraft/resources/ResourceKey;" - + "Ljava/util/function/Supplier;" + NBT + "Ljava/util/Optional;)" + NBT; - @Test public void advantageThenBaseMetalsPreservesBothHooks() throws Exception { check(false, false); } - @Test public void baseMetalsThenAdvantagePreservesBothHooks() throws Exception { check(true, false); } - - @Test public void descriptorBasedChunkHooksDoNotDependOnMethodNamesInEitherOrder() throws Exception { - check(false, true); - check(true, true); + @Test public void advantageThenBaseMetalsPreservesBothHookContracts() throws Exception { + check(List.of("basepower", "basemetals")); } - @Test public void separateEntityStorageStillUsesTheNativeRegionConverterThroughThePowerHook() throws Exception { - ClassNode node = nativeClass(ENTITIES); - String desc = "(" + NBT + "I)" + NBT; - assertEquals(1, nativeEntityCalls(node, desc).size()); - transform(node, "separate_entity_storage", false); - assertEquals(0, nativeEntityCalls(node, desc).size()); - assertEquals(1, calls(node, POWER, "upgradeEntityChunk", "(L" + REGION + ";" + desc.substring(1))); - - ClassNode missing = nativeClass(ENTITIES); - nativeEntityCalls(missing, desc).getFirst().desc = "(" + NBT + "J)" + NBT; - expectRejection(() -> transform(missing, "separate_entity_storage", false), "exactly one native entity-chunk"); - assertEquals(0, calls(missing, POWER, "upgradeEntityChunk", "(L" + REGION + ";" + desc.substring(1))); + @Test public void baseMetalsThenAdvantagePreservesBothHookContracts() throws Exception { + check(List.of("basemetals", "basepower")); } - private static void check(boolean baseFirst, boolean renamedChunk) throws Exception { - ClassNode world = nativeClass(WORLD); - applyBoth(world, "legacy_world_info", baseFirst); - MethodNode fixed = method(world, "readLevelDataTagFixed", WORLD_READ); - List readers = methods(fixed); - assertEquals(List.of("java/nio/file/Path", lastApplied(baseFirst), "java/nio/file/Path", - firstApplied(baseFirst), WORLD), readers.subList(0, 5).stream().map(call -> call.owner).toList()); - assertEquals("readLevelDataTagRaw", readers.get(4).name); - assertBoth(world, "prepareLegacyWorld", "(Ljava/io/File;)V"); - assertEquals(1, calls(method(world, "readLevelDataTagRaw", "(Ljava/nio/file/Path;)" + NBT), - METADATA, "remapRaw", "(" + NBT + ")" + NBT)); - String tag = "Lnet/minecraft/nbt/Tag;"; - assertEquals(1, calls(method(world, "readLightweightData", "(Ljava/nio/file/Path;)" + tag), - METADATA, "remapLightweight", "(" + tag + ")" + tag)); - assertEquals(1, calls(world, PLAYER, "updateNativeEmbeddedPlayer", - "(Lnet/minecraft/util/datafix/DataFixTypes;" + FIXER + DYNAMIC + "I)" + DYNAMIC)); - - ClassNode chunk = nativeClass(CHUNK); - MethodNode upgrade = method(chunk, "upgradeChunkTag", CHUNK_UPGRADE); - MethodNode read = method(chunk, "read", "(Lnet/minecraft/world/level/ChunkPos;)Ljava/util/concurrent/CompletableFuture;"); - if (renamedChunk) { - renameChunkMethod(chunk, upgrade); - renameChunkMethod(chunk, read); - renameChunkMethod(chunk, method(chunk, "getVersion", "(" + NBT + ")I")); - } - applyBoth(chunk, "legacy_chunk_status", baseFirst); - assertBoth(chunk, "prepareLegacyChunk", "(" + NBT + ")V"); - int returns = (int) Arrays.stream(upgrade.instructions.toArray()) - .filter(instruction -> instruction.getOpcode() == Opcodes.ARETURN).count(); - assertEquals("Review each native Forge 52 chunk-upgrade return", 2, returns); - assertEquals(returns, calls(upgrade, POWER, "finalizeLegacyChunk", "(" + NBT + ")" + NBT)); - assertEquals(returns, calls(upgrade, METALS, "finalizeLegacyChunk", "(" + NBT + ")" + NBT)); - assertEquals(1, calls(upgrade, POWER, "prepareLegacyEntities", "(" + NBT + ")V")); - assertEquals(1, calls(read, POWER, "copyChunkReadForMigration", - "(Ljava/util/concurrent/CompletableFuture;)Ljava/util/concurrent/CompletableFuture;")); - assertChunkOrder(upgrade, baseFirst); - - ClassNode worldgen = nativeClass(WORLDGEN); - applyBoth(worldgen, "legacy_worldgen_guard", baseFirst); - String guardDesc = "(L" + WORLDGEN + ";Lnet/minecraft/core/BlockPos;)Z"; - assertBoth(worldgen, "shouldBlockWorldgenWrite", guardDesc); - MethodNode guard = method(worldgen, "ensureCanWrite", "(Lnet/minecraft/core/BlockPos;)Z"); - List guardCalls = methods(guard); - assertGuard(guardCalls.get(0), lastApplied(baseFirst)); - assertGuard(guardCalls.get(1), firstApplied(baseFirst)); + @Test public void nativeChunkDescriptorsAndInvocationNamesFailClosedOnDrift() throws Exception { + Binding staging = binding("basepower", "SimpleRegionStorageMixin", "prepareInventories"); + assertEquals(UPGRADE, staging.selector()); + staging.assertAt("INVOKE"); + assertEquals("Lnet/minecraft/nbt/NbtUtils;getDataVersion(" + TAG + "I)I", staging.invocation()); + assertEquals(1, staging.invokeSites(staging.target())); + MethodNode nativeMethod = staging.nativeMethod(); + nativeMethod.name = "renamedUpgrade"; + assertThrows(AssertionError.class, staging::nativeMethod); + Binding fresh = binding("basepower", "SimpleRegionStorageMixin", "prepareInventories"); + calls(fresh.nativeMethod()).stream().filter(fresh::matches).findFirst().orElseThrow().name = "renamedGetVersion"; + assertEquals("Mixin selectors must not accept renamed native invocation names", 0, fresh.invokeSites(fresh.target())); + 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")); } - private static void assertChunkOrder(MethodNode upgrade, boolean baseFirst) { - List entries = methods(upgrade).subList(0, 4); - assertEquals(List.of("prepareLegacyChunk", "prepareLegacyChunk", "prepareLegacyEntities", "getVersion"), - List.of(entries.get(0).name, entries.get(1).name, entries.get(2).name, - entries.get(3).name.replace("renamed_", ""))); - assertEquals(List.of(lastApplied(baseFirst), firstApplied(baseFirst), POWER, CHUNK), - entries.stream().map(call -> call.owner).toList()); - assertEquals("(" + NBT + ")I", entries.get(3).desc); - for (AbstractInsnNode instruction : upgrade.instructions.toArray()) { - if (instruction.getOpcode() != Opcodes.ARETURN) continue; - assertTrue(instruction.getPrevious() instanceof MethodInsnNode); - MethodInsnNode last = (MethodInsnNode) instruction.getPrevious(); - assertTrue(last.getPrevious() instanceof MethodInsnNode); - MethodInsnNode before = (MethodInsnNode) last.getPrevious(); - assertEquals("finalizeLegacyChunk", before.name); - assertEquals("finalizeLegacyChunk", last.name); - assertEquals(firstApplied(baseFirst), before.owner); - assertEquals(lastApplied(baseFirst), last.owner); + @Test public void separateEntityStorageStillUsesTheNativeRegionConverterThroughThePowerHook() throws Exception { + ClassNode entities = read("net/minecraft/world/level/chunk/storage/EntityStorage"); + String twoArg = "(" + TAG + "I)" + TAG; + var nativeCalls = calls(entities).stream().filter(call -> call.owner.equals(REGION) + && call.name.equals("upgradeChunkTag") && call.desc.equals(twoArg)).toList(); + assertEquals(1, nativeCalls.size()); + ClassNode region = read(REGION); + MethodNode delegate = method(region, "upgradeChunkTag" + twoArg); + assertEquals(1, calls(delegate).stream().filter(call -> call.owner.equals(REGION) + && (call.name + call.desc).equals(UPGRADE)).count()); + binding("basepower", "SimpleRegionStorageMixin", "prepareChunk").assertAt("HEAD"); + binding("basepower", "SimpleRegionStorageMixin", "prepareInventories").assertAt("INVOKE"); + binding("basepower", "SimpleRegionStorageMixin", "finishChunk").assertAt("RETURN"); + nativeCalls.getFirst().desc = "(" + TAG + "J)" + TAG; + assertFalse("Wrong native overload cannot satisfy the entity-storage contract", calls(entities).stream() + .anyMatch(call -> call.owner.equals(REGION) && call.name.equals("upgradeChunkTag") && call.desc.equals(twoArg))); + } + + @Test public void bothRequiredConfigsAndAllNativePowerMixinsArePresent() throws Exception { + for (String mod : List.of("basepower", "basemetals")) { + JsonObject config; + try (var reader = new InputStreamReader(resource("/" + mod + ".mixins.json"), StandardCharsets.UTF_8)) { + config = JsonParser.parseReader(reader).getAsJsonObject(); + } + assertTrue(config.get("required").getAsBoolean()); + assertEquals("JAVA_21", config.get("compatibilityLevel").getAsString()); + 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", + "PlayerDataStorageMixin", "SimpleRegionStorageMixin", "WorldGenRegionMixin"), + config.getAsJsonArray("mixins").asList().stream().map(value -> value.getAsString()) + .collect(java.util.stream.Collectors.toSet())); } - } - - private static void assertGuard(MethodInsnNode call, String owner) { - assertEquals(owner, call.owner); - assertEquals("shouldBlockWorldgenWrite", call.name); - assertEquals(Opcodes.IFEQ, call.getNext().getOpcode()); - assertEquals(Opcodes.ICONST_0, call.getNext().getNext().getOpcode()); - AbstractInsnNode denied = call.getNext().getNext().getNext(); - assertEquals(Opcodes.IRETURN, denied.getOpcode()); - assertSame(((JumpInsnNode) call.getNext()).label, denied.getNext()); - } - - private static String firstApplied(boolean baseFirst) { return baseFirst ? METALS : POWER; } - private static String lastApplied(boolean baseFirst) { return baseFirst ? POWER : METALS; } - - private static List nativeEntityCalls(ClassNode node, String desc) { - return methods(node).stream().filter(call -> call.getOpcode() == Opcodes.INVOKEVIRTUAL - && call.owner.equals(REGION) && call.name.equals("upgradeChunkTag") && call.desc.equals(desc)).toList(); - } - - private static void assertBoth(ClassNode node, String name, String desc) { - assertEquals(node.name, 1, calls(node, POWER, name, desc)); - assertEquals(node.name, 1, calls(node, METALS, name, desc)); - } - - private static void renameChunkMethod(ClassNode node, MethodNode target) { - for (MethodInsnNode call : methods(node)) { - if (call.owner.equals(node.name) && call.name.equals(target.name) && call.desc.equals(target.desc)) { - call.name = "renamed_" + call.name; + assertNull(getClass().getResource("/coremods/poweradvantage_118_migration.js")); + assertFalse(java.nio.file.Files.exists(java.nio.file.Path.of("src/main/resources/META-INF/coremods.json"))); + } + + @Test public void numericIdExpansionIsRequiredAtNativeClassInitializationTail() throws Exception { + Binding expansion = binding("basepower", "BlockStateDataMixin", "expandLegacyIds"); + assertEquals("", expansion.selector()); + expansion.assertAt("TAIL"); + assertEquals("net/minecraft/util/datafix/fixes/BlockStateData", expansion.target().name); + for (String name : List.of("MAP", "BLOCK_DEFAULTS")) { + var field = expansion.mixin().fields.stream().filter(value -> value.name.equals(name)).findFirst().orElseThrow(); + assertEquals("[Lcom/mojang/serialization/Dynamic;", field.desc); + assertNotNull(expansion.target().fields.stream().filter(value -> value.name.equals(name) + && value.desc.equals(field.desc) && (value.access & Opcodes.ACC_STATIC) != 0).findFirst().orElseThrow()); + for (String annotation : List.of("Shadow", "Final", "Mutable")) { + annotation(field.visibleAnnotations, "Lorg/spongepowered/asm/mixin/" + annotation + ";"); } } - target.name = "renamed_" + target.name; - } - - private static void applyBoth(ClassNode node, String target, boolean baseFirst) throws Exception { - transform(node, target, baseFirst); - transform(node, target, !baseFirst); - } - - static void transform(ClassNode node, String target, boolean base) throws Exception { - String mod = base ? "basemetals" : "poweradvantage"; - String path = base ? "/coremods/basemetals_118_compatibility.js" : "/coremods/poweradvantage_118_migration.js"; - ScriptEngine engine = new ScriptEngineManager().getEngineByName("nashorn"); - assertNotNull("Transformer tests require Forge's bundled Nashorn engine", engine); - try (InputStreamReader reader = new InputStreamReader(resource(path), StandardCharsets.UTF_8)) { engine.eval(reader); } - engine.put("targetClass", node); - String entry = "initializeCoreMod()." + mod + "_" + target; - assertEquals("CLASS", engine.eval(entry + ".target.type")); - assertEquals(node.name.replace('/', '.'), engine.eval(entry + ".target.name")); - assertSame(node, engine.eval(entry + ".transformer(targetClass)")); - } - - static ClassNode nativeClass(String internalName) throws IOException { - ClassNode node = new ClassNode(); - try (InputStream input = resource("/" + internalName + ".class")) { - new ClassReader(input).accept(node, 0); + assertTrue(Arrays.stream(expansion.handler().instructions.toArray()) + .anyMatch(insn -> insn instanceof LdcInsnNode constant && Integer.valueOf(65536).equals(constant.cst))); + assertTrue(Arrays.stream(expansion.handler().instructions.toArray()) + .anyMatch(insn -> insn instanceof org.objectweb.asm.tree.IntInsnNode constant && constant.operand == 4096)); + } + + 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 before = binding(mod, "SimpleRegionStorageMixin", "prepareChunk"); + before.assertAt("HEAD"); + assertEquals(REGION, before.target().name); + assertEquals(UPGRADE, before.selector()); + assertFalse("Both HEAD hooks must be able to run", before.cancellable()); + assertCalls(before.handler(), hook, "prepareLegacyChunk", "(" + TAG + ")V"); + Binding after = binding(mod, "SimpleRegionStorageMixin", "finishChunk"); + after.assertAt("RETURN"); + assertEquals(UPGRADE, after.selector()); + assertFalse("Finalization must not short-circuit companion return hooks", after.cancellable()); + assertEquals("All native returns, including current-version early return, must be covered", 2, + Arrays.stream(after.nativeMethod().instructions.toArray()).filter(insn -> insn.getOpcode() == Opcodes.ARETURN).count()); + assertCalls(after.handler(), hook, "finalizeLegacyChunk", "(" + TAG + ")" + TAG); + assertFalse("In-place finalization must not cancel the return callback", + calls(after.handler()).stream().anyMatch(call -> call.name.equals("setReturnValue") + || call.name.equals("cancel"))); + + Binding worldgen = binding(mod, "WorldGenRegionMixin", "protectTerrain"); + worldgen.assertAt("HEAD"); + assertEquals("net/minecraft/server/level/WorldGenRegion", worldgen.target().name); + assertEquals("ensureCanWrite", worldgen.selector()); + assertTrue(worldgen.cancellable()); + assertCalls(worldgen.handler(), hook, "shouldBlockWorldgenWrite", + "(Lnet/minecraft/server/level/WorldGenRegion;Lnet/minecraft/core/BlockPos;)Z"); + assertCallbackResult(worldgen.handler()); + assertTrue("A blocked write must return false, never true", Arrays.stream(worldgen.handler().instructions.toArray()) + .anyMatch(insn -> insn.getOpcode() == Opcodes.ICONST_0)); + assertFalse(Arrays.stream(worldgen.handler().instructions.toArray()) + .anyMatch(insn -> insn.getOpcode() == Opcodes.ICONST_1)); } - return node; - } - - static InputStream resource(String name) { - InputStream input = LegacyTransformerOrderTest.class.getResourceAsStream(name); - assertNotNull("Missing pinned native dependency resource " + name, input); - return input; - } - - static void expectRejection(TransformerCall call, String message) throws Exception { - try { - call.run(); - fail("An unsupported native reader must abort transformation"); - } catch (ScriptException expected) { - assertTrue(expected.getMessage(), expected.getMessage().contains(message)); + Binding staging = binding("basepower", "SimpleRegionStorageMixin", "prepareInventories"); + staging.assertAt("INVOKE"); + assertEquals(1, calls(staging.nativeMethod()).stream().filter(staging::matches).count()); + assertCalls(staging.handler(), POWER, "prepareLegacyEntities", "(" + TAG + ")V"); + // At(INVOKE), with no shift/slice/ordinal, follows every non-cancellable HEAD injection in either priority order. + assertFalse(staging.cancellable()); + Binding read = binding("basepower", "SimpleRegionStorageMixin", "detachRead"); + read.assertAt("RETURN"); + assertTrue(read.cancellable()); + assertCalls(read.handler(), POWER, "copyChunkReadForMigration", + "(Ljava/util/concurrent/CompletableFuture;)Ljava/util/concurrent/CompletableFuture;"); + assertCallbackResult(read.handler()); + for (String name : List.of("remapRaw", "remapSummary")) { + Binding metadata = binding("basepower", "LevelStorageSourceMixin", name); + metadata.assertAt("RETURN"); + assertTrue(metadata.cancellable()); + assertCalls(metadata.handler(), "zone/moddev/mc/basepower/migration/LegacyWorldMetadata", + name.equals("remapRaw") ? "remapRaw" : "remapLightweight", + name.equals("remapRaw") ? "(" + TAG + ")" + TAG : "(Lnet/minecraft/nbt/Tag;)Lnet/minecraft/nbt/Tag;"); + assertCallbackResult(metadata.handler()); } } - @FunctionalInterface interface TransformerCall { void run() throws Exception; } - - static int calls(ClassNode node, String owner, String name, String desc) { - return node.methods.stream().mapToInt(method -> calls(method, owner, name, desc)).sum(); - } - - static int calls(MethodNode method, String owner, String name, String desc) { - return (int) methods(method).stream().filter(call -> { - if (!call.owner.equals(owner) || !call.name.equals(name) || !call.desc.equals(desc)) return false; - assertEquals(Opcodes.INVOKESTATIC, call.getOpcode()); - return true; - }).count(); - } - - private static List methods(ClassNode node) { - List all = new ArrayList<>(); - node.methods.forEach(method -> all.addAll(methods(method))); - return all; - } - - private static List methods(MethodNode method) { - return Arrays.stream(method.instructions.toArray()).filter(MethodInsnNode.class::isInstance) - .map(MethodInsnNode.class::cast).toList(); + private static void assertCallbackResult(MethodNode method) { + assertEquals(1, calls(method).stream().filter(call -> call.owner.equals( + "org/spongepowered/asm/mixin/injection/callback/CallbackInfoReturnable") + && call.name.equals("setReturnValue") && call.desc.equals("(Ljava/lang/Object;)V")).count()); } - private static MethodNode method(ClassNode node, String name, String desc) { - return node.methods.stream().filter(method -> method.name.equals(name) && method.desc.equals(desc)) - .findFirst().orElseThrow(() -> new AssertionError("Missing native method " + node.name + "." + name + desc)); + private static void assertCalls(MethodNode method, String owner, String name, String descriptor) { + assertEquals(method.name, 1, calls(method).stream().filter(call -> call.getOpcode() == Opcodes.INVOKESTATIC + && call.owner.equals(owner) && call.name.equals(name) && call.desc.equals(descriptor)).count()); } } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadataTest.java b/src/test/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadataTest.java index 91424dd0..c7b6c9ff 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadataTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/LegacyWorldMetadataTest.java @@ -26,7 +26,7 @@ public class LegacyWorldMetadataTest { root.put("fml", forge); LegacyWorldMetadata.remap(root, id -> false); assertFalse(registries.contains("minecraft:motive")); - assertEquals(snapshot, registries.getCompound("minecraft:painting_variant")); + assertEquals(snapshot, registries.getCompoundOrEmpty("minecraft:painting_variant")); CompoundTag once = root.copy(); LegacyWorldMetadata.remap(root, id -> false); assertEquals(once, root); diff --git a/src/test/java/zone/moddev/mc/basepower/migration/Native12111BiomeRegistryMappingTest.java b/src/test/java/zone/moddev/mc/basepower/migration/Native12111BiomeRegistryMappingTest.java new file mode 100644 index 00000000..f9d15c6f --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/migration/Native12111BiomeRegistryMappingTest.java @@ -0,0 +1,153 @@ +package zone.moddev.mc.basepower.migration; + +import com.mojang.serialization.Dynamic; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.SharedConstants; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.StringTag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.util.datafix.fixes.CavesAndCliffsRenames; +import net.minecraft.util.datafix.fixes.References; +import net.minecraft.world.level.biome.Biomes; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.eventbus.api.bus.EventBus; +import net.minecraftforge.eventbus.api.listener.Priority; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.IForgeRegistry; +import net.minecraftforge.registries.MissingMappingsEvent; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class Native12111BiomeRegistryMappingTest { + @BeforeClass public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test public void actualForgeBiomeRegistryCannotResolveTheNativeNetherTarget() { + assertEquals(Registries.BIOME, ForgeRegistries.BIOMES.getRegistryKey()); + assertFalse("Biomes are native data-registry keys, not bound Forge biome objects", + ForgeRegistries.BIOMES.containsKey(Biomes.NETHER_WASTES.identifier())); + var mapping = mappings(ForgeRegistries.BIOMES, List.of(id("nether"))).getFirst(); + assertRetired(mapping, id("nether"), 100); + assertEquals("minecraft:nether_wastes", fixName("minecraft:nether")); + } + + @Test public void everyNativeBiomeKeyRetiresOnlyItsObsoleteForgeIndex() throws Exception { + Set current = nativeBiomeKeys(); + assertEquals("Review the allowlist when changing the native target", 65, current.size()); + var before = Set.copyOf(ForgeRegistries.BIOMES.getKeys()); + for (Identifier key : current) assertFalse(key.toString(), ForgeRegistries.BIOMES.containsKey(key)); + var mappings = mappings(ForgeRegistries.BIOMES, new ArrayList<>(current)); + int index = 0; + for (Identifier key : current) assertRetired(mappings.get(index), key, 100 + index++); + assertEquals("Retiring a snapshot index must not register or replace biome objects", + before, ForgeRegistries.BIOMES.getKeys()); + } + + @Test public void everyCavesAndCliffsAndDeepWarmAliasAgreesWithNativeDfuWithoutCollapsingRegistryIds() throws Exception { + Map aliases = new LinkedHashMap<>(CavesAndCliffsRenames.RENAMES); + aliases.put("minecraft:deep_warm_ocean", "minecraft:warm_ocean"); + var mappings = mappings(ForgeRegistries.BIOMES, + aliases.keySet().stream().map(Identifier::parse).toList()); + Set current = nativeBiomeKeys(); + int index = 0; + for (var alias : aliases.entrySet()) { + Identifier target = Identifier.parse(alias.getValue()); + assertTrue("Alias target is a current native key: " + alias, current.contains(target)); + assertFalse("The test must exercise the real unbound Forge registry: " + target, + ForgeRegistries.BIOMES.containsKey(target)); + assertEquals(alias.toString(), alias.getValue(), fixName(alias.getKey())); + assertRetired(mappings.get(index), Identifier.parse(alias.getKey()), 100 + index++); + } + } + + @Test public void nativeDfuPreservesCurrentAndUnrecognizedBiomeNamesExactly() throws Exception { + for (Identifier key : nativeBiomeKeys()) assertEquals(key.toString(), key.toString(), fixName(key.toString())); + for (String name : List.of("minecraft:unknown_biome", "othermod:nether", "othermod:desert_hills", + "othermod:plains", "othermod:private_terrain")) { + assertEquals(name, name, fixName(name)); + } + } + + @Test public void unknownModdedAndWrongRegistryEntriesRemainUnresolved() { + List names = List.of(id("unknown_biome"), id("nether_typo"), id("deep_warm_ocean_typo"), + Identifier.parse("othermod:nether"), Identifier.parse("othermod:desert_hills"), + Identifier.parse("othermod:plains")); + for (var mapping : mappings(ForgeRegistries.BIOMES, names)) { + assertEquals(mapping.getKey().toString(), MissingMappingsEvent.Action.DEFAULT, property(mapping, "action")); + assertNull(property(mapping, "target")); + } + for (var mapping : mappings(ForgeRegistries.ITEMS, List.of(id("nether"), id("desert_hills"), id("plains")))) { + assertEquals(MissingMappingsEvent.Action.DEFAULT, property(mapping, "action")); + assertNull(property(mapping, "target")); + } + } + + private static Set nativeBiomeKeys() throws Exception { + Set keys = new LinkedHashSet<>(); + for (var field : Biomes.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) && field.getType() == ResourceKey.class) { + ResourceKey key = (ResourceKey) field.get(null); + assertTrue(field.getName(), key.isFor(Registries.BIOME)); + assertTrue(field.getName(), keys.add(key.identifier())); + } + } + return keys; + } + + 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); + assertEquals("DFU must not mutate the original saved name", name, original.asString().result().orElseThrow()); + return fixed.asString().result().orElseThrow(); + } + + private static List> mappings(IForgeRegistry active, List names) { + List> mappings = new ArrayList<>(); + for (Identifier name : names) mappings.add(new MissingMappingsEvent.Mapping<>(active, active, name, 100 + mappings.size())); + var group = BusGroup.create("basepower-native-biome-mapping-test"); + var bus = EventBus.create(group, MissingMappingsEvent.class); + bus.addListener(Priority.HIGHEST, (MissingMappingsEvent event) -> VanillaRegistryRemaps.remap(event, active)); + group.startup(); + try { + bus.post(new MissingMappingsEvent(active.getRegistryKey(), active, + new ArrayList>(mappings))); + } finally { + group.dispose(); + } + return mappings; + } + + private static void assertRetired(MissingMappingsEvent.Mapping mapping, Identifier original, int numericId) { + assertEquals(original.toString(), MissingMappingsEvent.Action.IGNORE, property(mapping, "action")); + assertEquals("Saved biome name is never rewritten by the Forge index handler", original, mapping.getKey()); + assertEquals("No target acquires an obsolete numeric ID", numericId, mapping.getId()); + assertNull("Actual biome data remains under native DFU, not an arbitrary Forge object", property(mapping, "target")); + } + + private static Object property(MissingMappingsEvent.Mapping mapping, String name) { + try { + var field = mapping.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(mapping); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + } + + private static Identifier id(String name) { + return Identifier.fromNamespaceAndPath("minecraft", name); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/migration/Native12111RegistryMigrationTest.java b/src/test/java/zone/moddev/mc/basepower/migration/Native12111RegistryMigrationTest.java new file mode 100644 index 00000000..3ebc0b4c --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/migration/Native12111RegistryMigrationTest.java @@ -0,0 +1,143 @@ +package zone.moddev.mc.basepower.migration; + +import com.mojang.serialization.Dynamic; +import java.lang.reflect.Proxy; +import java.util.List; +import net.minecraft.SharedConstants; +import net.minecraft.core.Registry; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.util.datafix.fixes.References; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.IForgeRegistry; +import net.minecraftforge.registries.MissingMappingsEvent; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class Native12111RegistryMigrationTest { + @BeforeClass public static void bootstrap() { SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); } + + @Test public void chainAliasesMatchNativeBlockAndItemDataFixers() { + Identifier target = id("iron_chain"); + assertEquals(target, VanillaRegistryRemaps.replacement(id("block"), id("chain"))); + assertEquals(target, VanillaRegistryRemaps.replacement(id("item"), id("chain"))); + assertTrue(ForgeRegistries.BLOCKS.containsKey(target)); + assertTrue(ForgeRegistries.ITEMS.containsKey(target)); + CompoundTag block = new CompoundTag(); + block.putString("Name", "minecraft:chain"); + CompoundTag properties = new CompoundTag(); + properties.putString("axis", "x"); + properties.putString("waterlogged", "true"); + block.put("Properties", properties); + var fixed = DataFixers.getDataFixer().update(References.BLOCK_STATE, + new Dynamic<>(NbtOps.INSTANCE, block), 3839, 4671); + 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); + 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"))); + assertNull(VanillaRegistryRemaps.replacement(id("item"), Identifier.parse("other:chain"))); + } + + @Test public void splitEntityIndexesRetireWithoutCollapsingActualBoatOrPotionData() { + for (String name : new String[] {"boat", "chest_boat", "potion"}) { + assertAction("entity_type", name, MissingMappingsEvent.Action.IGNORE); + assertFalse(ForgeRegistries.ENTITY_TYPES.containsKey(id(name))); + } + for (String wood : new String[] {"oak", "spruce", "birch", "jungle", "acacia", "cherry", "dark_oak", "mangrove", "bamboo"}) { + for (String old : new String[] {"boat", "chest_boat"}) { + CompoundTag entity = witness(old); + entity.putString("Type", wood); + String target = wood.equals("bamboo") ? (old.equals("boat") ? "bamboo_raft" : "bamboo_chest_raft") + : wood + "_" + old; + assertFixedEntity(entity, target); + } + } + for (String potion : new String[] {"splash_potion", "lingering_potion"}) { + CompoundTag entity = witness("potion"); + entity.put("Item", item(potion, 1)); + assertFixedEntity(entity, potion); + } + assertAction("entity_type", "cow", MissingMappingsEvent.Action.DEFAULT); + assertAction("entity_type", "unknown", MissingMappingsEvent.Action.DEFAULT); + assertAction("item", "boat", MissingMappingsEvent.Action.DEFAULT); + assertAction("entity_type", "other:boat", MissingMappingsEvent.Action.DEFAULT); + } + + @Test public void removedNeutralParrotImitationsDoNotBlockHistoricalRegistrySnapshots() { + for (String mob : new String[] {"enderman", "polar_bear", "wolf", "zombie_pigman"}) { + String name = "entity.parrot.imitate." + mob; + assertFalse("No native sound target exists for " + name, ForgeRegistries.SOUND_EVENTS.containsKey(id(name))); + assertAction("sound_event", name, MissingMappingsEvent.Action.IGNORE); + assertAction("item", name, MissingMappingsEvent.Action.DEFAULT); + assertAction("sound_event", "other:" + name, MissingMappingsEvent.Action.DEFAULT); + } + assertAction("sound_event", "entity.parrot.imitate.unknown", MissingMappingsEvent.Action.DEFAULT); + assertAction("sound_event", "entity.parrot.imitate.creeper", MissingMappingsEvent.Action.DEFAULT); + } + + @Test public void onlyExactRemovedSoundIndexesAreRetired() { + for (String name : new String[] {"entity.goat.screaming.horn_break", "entity.leash_knot.break", + "entity.leash_knot.place", "entity.puffer_fish.ambient", "entity.wolf.howl", "item.goat_horn.play"}) { + assertFalse(ForgeRegistries.SOUND_EVENTS.containsKey(id(name))); + assertAction("sound_event", name, MissingMappingsEvent.Action.IGNORE); + } + assertAction("sound_event", "entity.goat.horn_break", MissingMappingsEvent.Action.DEFAULT); + assertAction("sound_event", "unknown", MissingMappingsEvent.Action.DEFAULT); + assertAction("sound_event", "other:entity.wolf.howl", MissingMappingsEvent.Action.DEFAULT); + assertAction("item", "entity.wolf.howl", MissingMappingsEvent.Action.DEFAULT); + } + + private static CompoundTag item(String name, int count) { + CompoundTag stack = new CompoundTag(); + stack.putString("id", id(name).toString()); + stack.putInt("count", count); + return stack; + } + + private static CompoundTag witness(String name) { + CompoundTag entity = new CompoundTag(); + entity.putString("id", id(name).toString()); + entity.putInt("AdvWorksOpaqueWitness", 121111); + return entity; + } + + private static void assertFixedEntity(CompoundTag entity, String target) { + var fixed = DataFixers.getDataFixer().update(References.ENTITY, + new Dynamic<>(NbtOps.INSTANCE, entity), 3839, 4671); + assertEquals(id(target).toString(), fixed.get("id").asString().result().orElseThrow()); + assertEquals(121111, fixed.get("AdvWorksOpaqueWitness").asInt(-1)); + assertTrue(ForgeRegistries.ENTITY_TYPES.containsKey(id(target))); + } + + @SuppressWarnings("unchecked") + private static void assertAction(String registry, String name, MissingMappingsEvent.Action expected) { + var key = ResourceKey.createRegistryKey(id(registry)); + IForgeRegistry active = (IForgeRegistry) Proxy.newProxyInstance( + IForgeRegistry.class.getClassLoader(), new Class[] {IForgeRegistry.class}, (proxy, method, args) -> { + return switch (method.getName()) { + case "getRegistryKey" -> key; + case "getRegistryName" -> id(registry); + case "containsKey" -> false; + default -> throw new UnsupportedOperationException(method.getName()); + }; + }); + var mapping = new MissingMappingsEvent.Mapping<>(active, active, + name.contains(":") ? Identifier.parse(name) : id(name), 42); + VanillaRegistryRemaps.remap(new MissingMappingsEvent(key, active, List.of(mapping)), active); + try { + var field = mapping.getClass().getDeclaredField("action"); + field.setAccessible(true); + assertEquals(registry + "/" + name, expected, field.get(mapping)); + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + } + + private static Identifier id(String name) { return Identifier.fromNamespaceAndPath("minecraft", name); } +} diff --git a/src/test/java/zone/moddev/mc/basepower/migration/NativeMigrationMixinContract.java b/src/test/java/zone/moddev/mc/basepower/migration/NativeMigrationMixinContract.java new file mode 100644 index 00000000..ca732b4e --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/migration/NativeMigrationMixinContract.java @@ -0,0 +1,164 @@ +package zone.moddev.mc.basepower.migration; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.AnnotationNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +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.SourceInterpreter; +import org.objectweb.asm.tree.analysis.SourceValue; +import static org.junit.Assert.*; + +/** Checks the real compiled mixin selectors against the pinned native bytecode, without simulating weaving. */ +final class NativeMigrationMixinContract { + static final String STORAGE = "net/minecraft/world/level/storage/"; + static final String REGION = "net/minecraft/world/level/chunk/storage/SimpleRegionStorage"; + static final String FIX_TYPES = "net/minecraft/util/datafix/DataFixTypes"; + 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; + + private NativeMigrationMixinContract() {} + + static ClassNode read(String name) throws IOException { + ClassNode result = new ClassNode(); + try (InputStream input = resource("/" + name + ".class")) { + new ClassReader(input).accept(result, 0); + } + return result; + } + + static InputStream resource(String name) { + InputStream input = NativeMigrationMixinContract.class.getResourceAsStream(name); + assertNotNull("Missing pinned resource " + name, input); + return input; + } + + static List calls(MethodNode method) { + return Arrays.stream(method.instructions.toArray()).filter(MethodInsnNode.class::isInstance) + .map(MethodInsnNode.class::cast).toList(); + } + + static List calls(ClassNode type) { + return type.methods.stream().flatMap(method -> calls(method).stream()).toList(); + } + + static MethodNode method(ClassNode owner, String selector) { + List found = owner.methods.stream().filter(method -> selector.equals(method.name) + || selector.equals(method.name + method.desc)).toList(); + assertEquals("Exact native selector " + owner.name + "." + selector, 1, found.size()); + return found.getFirst(); + } + + static AnnotationNode annotation(List annotations, String descriptor) { + assertNotNull("Missing annotations for " + descriptor, annotations); + return annotations.stream().filter(value -> value.desc.equals(descriptor)).findFirst() + .orElseThrow(() -> new AssertionError("Missing annotation " + descriptor)); + } + + static Object value(AnnotationNode annotation, String name, Object fallback) { + if (annotation.values != null) for (int index = 0; index < annotation.values.size(); index += 2) { + if (name.equals(annotation.values.get(index))) return annotation.values.get(index + 1); + } + return fallback; + } + + @SuppressWarnings("unchecked") + static Binding binding(String mod, String mixin, String handler) throws IOException { + ClassNode owner = read("zone/moddev/mc/" + mod + "/mixin/" + mixin); + var target = annotation(owner.invisibleAnnotations, "Lorg/spongepowered/asm/mixin/Mixin;"); + List targets = (List) value(target, "value", List.of()); + assertEquals("One native mixin target", 1, targets.size()); + MethodNode callback = method(owner, mod + "$" + handler); + List injectors = callback.visibleAnnotations.stream().filter(ann -> + ann.desc.equals("Lorg/spongepowered/asm/mixin/injection/Inject;") + || ann.desc.equals("Lorg/spongepowered/asm/mixin/injection/Redirect;")).toList(); + assertEquals("One native injector", 1, injectors.size()); + AnnotationNode injector = injectors.getFirst(); + List selectors = (List) value(injector, "method", List.of()); + assertEquals("One explicit selector", 1, selectors.size()); + Object atValue = value(injector, "at", null); + AnnotationNode at; + if (atValue instanceof List list) { + assertEquals("One injection point", 1, list.size()); + at = (AnnotationNode) list.getFirst(); + } else at = (AnnotationNode) atValue; + assertNotNull("Missing native injection point", at); + return new Binding(owner, callback, injector, at, read(targets.getFirst().getInternalName()), selectors.getFirst()); + } + + record Binding(ClassNode mixin, MethodNode handler, AnnotationNode injector, AnnotationNode at, + ClassNode target, String selector) { + String point() { return (String) value(at, "value", ""); } + String invocation() { return (String) value(at, "target", ""); } + boolean cancellable() { return Boolean.TRUE.equals(value(injector, "cancellable", false)); } + + MethodNode nativeMethod() { + assertNotEquals("Wildcard redirects have several native methods", "*", selector); + return method(target, selector); + } + + void assertAt(String expected) { + assertEquals(expected, point()); + assertFalse("No ordinal may silently omit an injection", at.values.contains("ordinal")); + assertFalse("No shifted native injection", at.values.contains("shift")); + assertFalse("No slices may omit return paths", injector.values.contains("slice")); + assertTrue("Required injector cannot be disabled", ((Number) value(injector, "require", 1)).intValue() >= 1); + if (!selector.equals("*")) { + MethodNode nativeMethod = nativeMethod(); + assertEquals("Handler/native static ABI", nativeMethod.access & Opcodes.ACC_STATIC, + handler.access & Opcodes.ACC_STATIC); + String nativeArgs = nativeMethod.desc.substring(1, nativeMethod.desc.indexOf(')')); + String callback = nativeMethod.desc.endsWith("V") ? "CallbackInfo" : "CallbackInfoReturnable"; + assertEquals("Exact native callback ABI", "(" + nativeArgs + + "Lorg/spongepowered/asm/mixin/injection/callback/" + callback + ";)V", handler.desc); + } + } + + int invokeSites(ClassNode nativeClass) { + return (int) calls(nativeClass).stream().filter(this::matches).count(); + } + + boolean matches(MethodInsnNode call) { + return invocation().equals("L" + call.owner + ";" + call.name + call.desc); + } + + 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; + } + + MethodNode assertOnePlayerSite(ClassNode nativeClass) throws Exception { + List methods = playerMethods(nativeClass); + assertEquals("Exactly one native PLAYER call: " + nativeClass.name, 1, methods.size()); + return methods.getFirst(); + } + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/migration/NativeMixedListGuardsTest.java b/src/test/java/zone/moddev/mc/basepower/migration/NativeMixedListGuardsTest.java new file mode 100644 index 00000000..20b7b209 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/migration/NativeMixedListGuardsTest.java @@ -0,0 +1,111 @@ +package zone.moddev.mc.basepower.migration; + +import java.util.List; +import java.util.Set; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class NativeMixedListGuardsTest { + @BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + AdvantageLegacyMigrations.registerPowerAdvantage(); + } + + @Test public void mixedEntityAttributesPassengersAndRootsFailWithoutPartialMutation() { + for (String key : List.of("Attributes", "Passengers", "Entities")) { + CompoundTag entity = new CompoundTag(); + entity.putString("id", "foreign:vehicle"); + entity.putLong("UUIDMost", 0L); + entity.putLong("UUIDLeast", 17L); + ListTag mixed = new ListTag(); + mixed.add(new CompoundTag()); + mixed.add(StringTag.valueOf("foreign-witness")); + CompoundTag level = new CompoundTag(); + ListTag entities = new ListTag(); + entities.add(entity); + level.put("Entities", entities); + if (key.equals("Entities")) level.put(key, mixed); + else entity.put(key, mixed); + CompoundTag root = new CompoundTag(); + root.putInt("DataVersion", 1343); + root.put("Level", level); + CompoundTag before = root.copy(); + assertThrows(key, IllegalStateException.class, () -> LegacyEntityDataFixes.prepare(root)); + assertEquals(key, before, root); + } + } + + @Test public void typedMachineTileGuardMarksThePassWithoutChangingAMixedNativePayload() { + CompoundTag tile = new CompoundTag(); + tile.putString("id", "basepower:conveyor"); + ListTag tiles = new ListTag(); + tiles.add(tile); + tiles.add(StringTag.valueOf("foreign-witness")); + CompoundTag level = new CompoundTag(); + level.put("TileEntities", tiles); + CompoundTag root = new CompoundTag(); + root.putInt("DataVersion", 512); + root.put("Level", level); + CompoundTag before = root.copy(); + LegacyMachineItemDataFixes.prepare(root); + before.putBoolean("AdvantageLegacyMachineItemDataFix", true); + assertEquals(before, root); + LegacyMachineItemDataFixes.prepare(root); + assertEquals("The prepared marker remains idempotent", before, root); + } + + @Test public void typedEnchantmentGuardsReturnDetachedEmptyListsRatherThanPartialRecords() throws Exception { + for (Class owner : List.of(LegacyPlayerDataFixes.class, LegacyMachineItemDataFixes.class)) { + var helper = owner.getDeclaredMethod("compoundList", CompoundTag.class, String.class); + helper.setAccessible(true); + CompoundTag tag = new CompoundTag(); + ListTag mixed = new ListTag(); + CompoundTag enchantment = new CompoundTag(); + enchantment.putString("id", "steamadvantage:steam_capacity"); + enchantment.putShort("lvl", (short) 3); + mixed.add(enchantment); + mixed.add(StringTag.valueOf("foreign-witness")); + tag.put("StoredEnchantments", mixed); + CompoundTag before = tag.copy(); + ListTag result = (ListTag) helper.invoke(null, tag, "StoredEnchantments"); + assertTrue(owner.getName(), result.isEmpty()); + result.add(new CompoundTag()); + assertEquals(owner.getName(), before, tag); + ListTag valid = new ListTag(); + valid.add(enchantment); + tag.put("StoredEnchantments", valid); + assertSame("Valid typed lists retain the native mutable view", valid, + helper.invoke(null, tag, "StoredEnchantments")); + } + } + + @Test public void mixedModAndPackListsKeepEveryOriginalEntry() { + for (String key : List.of("LoadingModList", "Enabled", "Disabled")) { + ListTag mixed = new ListTag(); + CompoundTag mod = new CompoundTag(); + mod.putString("ModId", "poweradvantage"); + mixed.add(key.equals("LoadingModList") ? mod : StringTag.valueOf("mod:poweradvantage")); + mixed.add(key.equals("LoadingModList") ? StringTag.valueOf("foreign-witness") : mod); + CompoundTag root = new CompoundTag(); + if (key.equals("LoadingModList")) { + CompoundTag forge = new CompoundTag(); + forge.put(key, mixed); + root.put("fml", forge); + } else { + CompoundTag packs = new CompoundTag(); + packs.put(key, mixed); + CompoundTag data = new CompoundTag(); + data.put("DataPacks", packs); + root.put("Data", data); + } + CompoundTag before = root.copy(); + LegacyWorldMetadata.remap(root, Set.of("basepower")::contains); + assertEquals(key, before, root); + } + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/migration/NativeWolfTestWorld.java b/src/test/java/zone/moddev/mc/basepower/migration/NativeWolfTestWorld.java index 3361caca..593c9839 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/NativeWolfTestWorld.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/NativeWolfTestWorld.java @@ -3,41 +3,90 @@ import com.mojang.serialization.Lifecycle; import java.lang.reflect.Field; import java.util.List; -import java.util.function.Supplier; -import net.minecraft.core.HolderSet; import net.minecraft.core.MappedRegistry; import net.minecraft.core.Registry; import net.minecraft.core.RegistryAccess; import net.minecraft.core.registries.Registries; -import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerLevel; -import net.minecraft.util.profiling.InactiveProfiler; -import net.minecraft.util.profiling.ProfilerFiller; -import net.minecraft.world.entity.animal.WolfVariant; -import net.minecraft.world.entity.animal.WolfVariants; import net.minecraft.world.level.Level; final class NativeWolfTestWorld { private NativeWolfTestWorld() {} static ServerLevel create() throws ReflectiveOperationException { + registerNativeAttributes(); Class allocator = Class.forName("sun.misc.Unsafe"); Field unsafe = allocator.getDeclaredField("theUnsafe"); unsafe.setAccessible(true); ServerLevel world = (ServerLevel) allocator.getMethod("allocateInstance", Class.class) .invoke(unsafe.get(null), ServerLevel.class); set(world, "dimension", Level.NETHER); - set(world, "profiler", (Supplier) () -> InactiveProfiler.INSTANCE); - // The native wolf constructor now resolves its default variant in the world's registry. - var variants = new MappedRegistry<>(Registries.WOLF_VARIANT, Lifecycle.stable()); - Registry.register(variants, WolfVariants.PALE, new WolfVariant( - ResourceLocation.parse("minecraft:entity/wolf/wolf"), - ResourceLocation.parse("minecraft:entity/wolf/wolf_tame"), - ResourceLocation.parse("minecraft:entity/wolf/wolf_angry"), HolderSet.direct())); - set(world, "registryAccess", new RegistryAccess.ImmutableRegistryAccess(List.of(variants)).freeze()); + set(world, "registryAccess", RegistriesHolder.ACCESS); + var server = (net.minecraft.server.dedicated.DedicatedServer) allocator.getMethod("allocateInstance", Class.class) + .invoke(unsafe.get(null), net.minecraft.server.dedicated.DedicatedServer.class); + Field subscribers = net.minecraft.server.MinecraftServer.class.getDeclaredField("debugSubscribers"); + subscribers.setAccessible(true); + subscribers.set(server, new net.minecraft.util.debug.ServerDebugSubscribers(server)); + Field serverField = ServerLevel.class.getDeclaredField("server"); + serverField.setAccessible(true); + serverField.set(world, server); return world; } + private static synchronized void registerNativeAttributes() throws ReflectiveOperationException { + var wolf = net.minecraft.world.entity.EntityType.WOLF; + if (!net.minecraftforge.common.ForgeMod.SWIM_SPEED.isPresent() + || net.minecraft.world.entity.ai.attributes.DefaultAttributes.getSupplier(wolf) + .hasAttribute(net.minecraftforge.common.ForgeMod.SWIM_SPEED.getHolder().orElseThrow())) return; + var eventType = net.minecraftforge.event.entity.EntityAttributeModificationEvent.class; + var nativeHandler = net.minecraftforge.common.ForgeMod.class.getDeclaredMethod("onRegisterAttributes", eventType); + nativeHandler.setAccessible(true); + // Dispatch Forge's real default-attribute lifecycle outside a mod-loader launch. + var listener = net.minecraftforge.event.entity.EntityAttributeModificationEvent.BUS.addListener(event -> { + try { + nativeHandler.invoke(null, event); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Native Forge attribute registration failed", failure); + } + }); + try { + net.minecraftforge.common.ForgeHooks.modifyAttributes(); + } finally { + net.minecraftforge.event.entity.EntityAttributeModificationEvent.BUS.removeListener(listener); + } + } + + private static final class RegistriesHolder { + private static final RegistryAccess ACCESS = createRegistries(); + } + + private static RegistryAccess createRegistries() { + var vanilla = net.minecraft.data.registries.VanillaRegistries.createLookup(); + return new RegistryAccess.ImmutableRegistryAccess(List.of( + copy(vanilla, Registries.WOLF_VARIANT), copy(vanilla, Registries.WOLF_SOUND_VARIANT), + copy(vanilla, Registries.PAINTING_VARIANT))).freeze(); + } + + private static Registry copy(net.minecraft.core.HolderLookup.Provider vanilla, + net.minecraft.resources.ResourceKey> key) { + var registry = new MappedRegistry(key, Lifecycle.stable()); + vanilla.lookupOrThrow(key).listElements().forEach(holder -> + registry.register(holder.key(), holder.value(), net.minecraft.core.RegistrationInfo.BUILT_IN)); + return registry; + } + + static void load(net.minecraft.world.entity.Entity entity, net.minecraft.nbt.CompoundTag saved) { + entity.load(net.minecraft.world.level.storage.TagValueInput.create( + net.minecraft.util.ProblemReporter.DISCARDING, entity.registryAccess(), saved)); + } + + static net.minecraft.nbt.CompoundTag save(net.minecraft.world.entity.Entity entity) { + var output = net.minecraft.world.level.storage.TagValueOutput.createWithContext( + net.minecraft.util.ProblemReporter.DISCARDING, entity.registryAccess()); + entity.saveWithoutId(output); + return output.buildResult(); + } + private static void set(ServerLevel world, String name, Object value) throws ReflectiveOperationException { Field field = Level.class.getDeclaredField(name); field.setAccessible(true); diff --git a/src/test/java/zone/moddev/mc/basepower/migration/RetiredProfessionSnapshotTest.java b/src/test/java/zone/moddev/mc/basepower/migration/RetiredProfessionSnapshotTest.java index d3a20a84..3caaca43 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/RetiredProfessionSnapshotTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/RetiredProfessionSnapshotTest.java @@ -13,8 +13,8 @@ public class RetiredProfessionSnapshotTest { player.putInt("XpLevel", 34); root.put("Player", player); assertTrue(LegacyWorldDataHook.removeRetiredVanillaProfessionRegistry(root)); - assertFalse(root.getCompound(namespace).getCompound("Registries").contains("minecraft:villagerprofessions")); - assertEquals(player, root.getCompound("Player")); + assertFalse(root.getCompoundOrEmpty(namespace).getCompoundOrEmpty("Registries").contains("minecraft:villagerprofessions")); + assertEquals(player, root.getCompoundOrEmpty("Player")); assertFalse(LegacyWorldDataHook.removeRetiredVanillaProfessionRegistry(root)); } } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/SeparateEntityStorageTest.java b/src/test/java/zone/moddev/mc/basepower/migration/SeparateEntityStorageTest.java index 29932dd3..fad92c10 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/SeparateEntityStorageTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/SeparateEntityStorageTest.java @@ -20,14 +20,14 @@ public class SeparateEntityStorageTest { CompoundTag staged = LegacyWorldDataHook.prepareEntityChunkForMigration(source); assertEquals(before, source); assertNotSame(source, staged); - assertEquals(0, staged.getList("Entities", 10).size()); + assertEquals(0, staged.getListOrEmpty("Entities").size()); LegacyWorldDataHook.finalizeLegacyChunk(staged); - CompoundTag entity = staged.getList("Entities", 10).getCompound(0); - assertEquals("foreign:machine_witness", entity.getString("id")); - assertEquals("keep this payload", entity.getString("ForeignState")); - assertEquals(new UUID(0, 17), entity.getUUID("UUID")); - assertEquals(new UUID(9, 0), entity.getList("Passengers", 10).getCompound(0).getUUID("UUID")); - assertArrayEquals(new int[] {7, -8}, staged.getIntArray("Position")); + CompoundTag entity = staged.getListOrEmpty("Entities").getCompoundOrEmpty(0); + assertEquals("foreign:machine_witness", entity.getStringOr("id", "")); + assertEquals("keep this payload", entity.getStringOr("ForeignState", "")); + assertEquals(new UUID(0, 17), entity.read("UUID", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); + assertEquals(new UUID(9, 0), entity.getListOrEmpty("Passengers").getCompoundOrEmpty(0).read("UUID", net.minecraft.core.UUIDUtil.CODEC).orElseThrow()); + assertArrayEquals(new int[] {7, -8}, staged.getIntArray("Position").orElseThrow()); assertFalse(staged.contains("AdvantageLegacyEntityDataFix")); assertEquals(before, source); } diff --git a/src/test/java/zone/moddev/mc/basepower/migration/VanillaRegistryMappingEventTest.java b/src/test/java/zone/moddev/mc/basepower/migration/VanillaRegistryMappingEventTest.java index e18bcdf9..8b838175 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/VanillaRegistryMappingEventTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/VanillaRegistryMappingEventTest.java @@ -2,7 +2,7 @@ import java.lang.reflect.Proxy; import java.util.Collections; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; import net.minecraft.resources.ResourceKey; import net.minecraftforge.registries.MissingMappingsEvent; @@ -11,8 +11,8 @@ import static org.junit.Assert.*; public class VanillaRegistryMappingEventTest { - private static final ResourceLocation SOURCE = ResourceLocation.parse("minecraft:zombie_pigman_spawn_egg"); - private static final ResourceLocation TARGET = ResourceLocation.parse("minecraft:zombified_piglin_spawn_egg"); + private static final Identifier SOURCE = Identifier.parse("minecraft:zombie_pigman_spawn_egg"); + private static final Identifier TARGET = Identifier.parse("minecraft:zombified_piglin_spawn_egg"); @Test public void resolvesTheTargetFromActiveNotTheIncompleteStagingRegistry() { Entry target = new Entry(); @@ -27,20 +27,25 @@ public class VanillaRegistryMappingEventTest { @Test public void aLaterExplicitForgeDecisionOverridesTheCompatibilityMapping() { IForgeRegistry active = registry(new Entry(), true); MissingMappingsEvent.Mapping mapping = new MissingMappingsEvent.Mapping<>(active, active, SOURCE, 42); - var bus = net.minecraftforge.eventbus.api.BusBuilder.builder().build(); - bus.addListener(net.minecraftforge.eventbus.api.EventPriority.HIGHEST, + var group = net.minecraftforge.eventbus.api.bus.BusGroup.create("basepower-migration-test"); + var bus = net.minecraftforge.eventbus.api.bus.EventBus.create(group, MissingMappingsEvent.class); + bus.addListener(net.minecraftforge.eventbus.api.listener.Priority.HIGHEST, (MissingMappingsEvent event) -> VanillaRegistryRemaps.remap(event, active)); - bus.addListener(net.minecraftforge.eventbus.api.EventPriority.NORMAL, + bus.addListener(net.minecraftforge.eventbus.api.listener.Priority.NORMAL, (MissingMappingsEvent event) -> mapping.ignore()); - bus.start(); - bus.post(event(active, mapping)); + group.startup(); + try { + bus.post(event(active, mapping)); + } finally { + group.dispose(); + } assertEquals(MissingMappingsEvent.Action.IGNORE, property(mapping, "action")); } @Test public void leavesUnrecognizedEntriesUnresolvedForForgeToReport() { IForgeRegistry active = registry(new Entry(), true); MissingMappingsEvent.Mapping mapping = new MissingMappingsEvent.Mapping<>(active, active, - ResourceLocation.parse("othermod:zombie_pigman_spawn_egg"), 42); + Identifier.parse("othermod:zombie_pigman_spawn_egg"), 42); VanillaRegistryRemaps.remap(event(active, mapping), active); assertEquals(MissingMappingsEvent.Action.DEFAULT, property(mapping, "action")); } @@ -60,7 +65,7 @@ public class VanillaRegistryMappingEventTest { IForgeRegistry active = registry(null, false, "minecraft:memory_module_type"); for (String name : new String[] {"golem_last_seen_time", "opened_doors"}) { MissingMappingsEvent.Mapping mapping = new MissingMappingsEvent.Mapping<>(active, active, - ResourceLocation.fromNamespaceAndPath("minecraft", name), 42); + Identifier.fromNamespaceAndPath("minecraft", name), 42); MissingMappingsEvent event = new MissingMappingsEvent(key("minecraft:memory_module_type"), active, Collections.singletonList(mapping)); VanillaRegistryRemaps.remap(event, active); @@ -70,17 +75,17 @@ public class VanillaRegistryMappingEventTest { } @Test public void neverRetiresOtherModsOrCurrentMemoryTypes() { - ResourceLocation registry = ResourceLocation.parse("minecraft:memory_module_type"); - assertFalse(VanillaRegistryRemaps.retiredTransientMemory(registry, ResourceLocation.parse("othermod:opened_doors"))); - assertFalse(VanillaRegistryRemaps.retiredTransientMemory(registry, ResourceLocation.parse("minecraft:golem_detected_recently"))); - assertFalse(VanillaRegistryRemaps.retiredTransientMemory(ResourceLocation.parse("minecraft:item"), - ResourceLocation.parse("minecraft:opened_doors"))); + Identifier registry = Identifier.parse("minecraft:memory_module_type"); + assertFalse(VanillaRegistryRemaps.retiredTransientMemory(registry, Identifier.parse("othermod:opened_doors"))); + assertFalse(VanillaRegistryRemaps.retiredTransientMemory(registry, Identifier.parse("minecraft:golem_detected_recently"))); + assertFalse(VanillaRegistryRemaps.retiredTransientMemory(Identifier.parse("minecraft:item"), + Identifier.parse("minecraft:opened_doors"))); } @Test public void retiresOnlyTheTwoPointsOfInterestRemovedByVanilla() { IForgeRegistry active = registry(null, false, "minecraft:point_of_interest_type"); for (String id : new String[] {"minecraft:nitwit", "minecraft:unemployed", "minecraft:home", "othermod:nitwit"}) { - var mapping = new MissingMappingsEvent.Mapping<>(active, active, ResourceLocation.parse(id), 42); + var mapping = new MissingMappingsEvent.Mapping<>(active, active, Identifier.parse(id), 42); VanillaRegistryRemaps.remap(event(active, mapping), active); boolean retired = id.equals("minecraft:nitwit") || id.equals("minecraft:unemployed"); assertEquals(id, retired ? MissingMappingsEvent.Action.IGNORE : MissingMappingsEvent.Action.DEFAULT, @@ -92,7 +97,7 @@ public class VanillaRegistryMappingEventTest { @Test public void retiresOnlyTheRemovedVanillaEmptyPotionIndex() { IForgeRegistry active = registry(null, false, "minecraft:potion"); for (String id : new String[] {"minecraft:empty", "minecraft:water", "othermod:empty"}) { - var mapping = new MissingMappingsEvent.Mapping<>(active, active, ResourceLocation.parse(id), 42); + var mapping = new MissingMappingsEvent.Mapping<>(active, active, Identifier.parse(id), 42); VanillaRegistryRemaps.remap(event(active, mapping), active); assertEquals(id, id.equals("minecraft:empty") ? MissingMappingsEvent.Action.IGNORE : MissingMappingsEvent.Action.DEFAULT, property(mapping, "action")); @@ -103,7 +108,7 @@ public class VanillaRegistryMappingEventTest { @Test public void movedPaintingsRetireOnlyTheirKnownStaticIndexes() { IForgeRegistry active = registry(null, false, "minecraft:painting_variant"); for (String id : new String[] {"minecraft:alban", "minecraft:aztec", "minecraft:unknown", "othermod:alban"}) { - var mapping = new MissingMappingsEvent.Mapping<>(active, active, ResourceLocation.parse(id), 42); + var mapping = new MissingMappingsEvent.Mapping<>(active, active, Identifier.parse(id), 42); VanillaRegistryRemaps.remap(event(active, mapping), active); assertEquals(id, id.equals("minecraft:alban") || id.equals("minecraft:aztec") ? MissingMappingsEvent.Action.IGNORE : MissingMappingsEvent.Action.DEFAULT, property(mapping, "action")); @@ -116,7 +121,7 @@ private static MissingMappingsEvent event(IForgeRegistry staging, Missing } private static ResourceKey> key(String id) { - return ResourceKey.createRegistryKey(ResourceLocation.parse(id)); + return ResourceKey.createRegistryKey(Identifier.parse(id)); } // Forge keeps the result fields package-private for its snapshot loader. @@ -140,7 +145,7 @@ private static IForgeRegistry registry(Entry target, boolean contains, St new Class[] {IForgeRegistry.class}, (proxy, method, args) -> { switch (method.getName()) { case "getRegistryKey": return key(id); - case "getRegistryName": return ResourceLocation.parse(id); + case "getRegistryName": return Identifier.parse(id); case "containsKey": return contains && TARGET.equals(args[0]); case "getValue": return target; case "getKey": return args[0] == target ? TARGET : null; diff --git a/src/test/java/zone/moddev/mc/basepower/migration/VanillaRegistryRemapsTest.java b/src/test/java/zone/moddev/mc/basepower/migration/VanillaRegistryRemapsTest.java index 7ec556d0..0b2d01eb 100644 --- a/src/test/java/zone/moddev/mc/basepower/migration/VanillaRegistryRemapsTest.java +++ b/src/test/java/zone/moddev/mc/basepower/migration/VanillaRegistryRemapsTest.java @@ -5,7 +5,7 @@ import net.minecraft.SharedConstants; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtOps; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.util.datafix.DataFixers; import net.minecraft.util.datafix.fixes.References; import org.junit.Test; @@ -14,17 +14,17 @@ public class VanillaRegistryRemapsTest { @org.junit.Test public void trialSpawnerRenamesResolveToActualNativeSounds() { net.minecraft.SharedConstants.tryDetectVersion(); net.minecraft.server.Bootstrap.bootStrap(); - var registry = net.minecraft.resources.ResourceLocation.parse("minecraft:sound_event"); + var registry = net.minecraft.resources.Identifier.parse("minecraft:sound_event"); for (String[] pair : new String[][] { {"block.trial_spawner.ambient_charged", "block.trial_spawner.ambient_ominous"}, {"block.trial_spawner.charge_activate", "block.trial_spawner.ominous_activate"}}) { var actual = VanillaRegistryRemaps.replacement(registry, - net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("minecraft", pair[0])); - assertEquals(net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("minecraft", pair[1]), actual); + net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", pair[0])); + assertEquals(net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", pair[1]), actual); assertTrue(net.minecraftforge.registries.ForgeRegistries.SOUND_EVENTS.containsKey(actual)); } assertNull(VanillaRegistryRemaps.replacement(registry, - net.minecraft.resources.ResourceLocation.parse("othermod:block.trial_spawner.charge_activate"))); + net.minecraft.resources.Identifier.parse("othermod:block.trial_spawner.charge_activate"))); } @Test public void retainsBothGrassPathRegistrySnapshots() { @@ -97,13 +97,13 @@ public class VanillaRegistryRemapsTest { } @Test public void onlyRetiresBiomeIndexesHandledByTheNativeDataFixer() { - ResourceLocation biomes = id("worldgen/biome"); + Identifier biomes = id("worldgen/biome"); assertEquals(id("desert"), VanillaRegistryRemaps.vanillaBiomeFixTarget(biomes, id("desert_hills"))); assertEquals(id("desert"), VanillaRegistryRemaps.vanillaBiomeFixTarget(biomes, id("desert_lakes"))); assertEquals(id("warm_ocean"), VanillaRegistryRemaps.vanillaBiomeFixTarget(biomes, id("deep_warm_ocean"))); assertNull(VanillaRegistryRemaps.vanillaBiomeFixTarget(biomes, id("plains"))); assertNull(VanillaRegistryRemaps.vanillaBiomeFixTarget(biomes, id("unknown_biome"))); - assertNull(VanillaRegistryRemaps.vanillaBiomeFixTarget(biomes, ResourceLocation.parse("example:desert_hills"))); + assertNull(VanillaRegistryRemaps.vanillaBiomeFixTarget(biomes, Identifier.parse("example:desert_hills"))); assertNull(VanillaRegistryRemaps.vanillaBiomeFixTarget(id("block"), id("desert_hills"))); } @@ -121,7 +121,7 @@ private static void assertBiomeFix(String from, String to) { } @Test public void leavesOtherModsAndUnrecognizedVanillaEntriesAlone() { - assertNull(VanillaRegistryRemaps.replacement(id("item"), ResourceLocation.parse("othermod:zombie_pigman_spawn_egg"))); + assertNull(VanillaRegistryRemaps.replacement(id("item"), Identifier.parse("othermod:zombie_pigman_spawn_egg"))); assertNull(VanillaRegistryRemaps.replacement(id("item"), id("unknown_item"))); assertNull(VanillaRegistryRemaps.replacement(id("sound_event"), id("music.unknown"))); } @@ -135,5 +135,5 @@ private static void expect(String registry, String from, String to) { assertEquals(id(to), VanillaRegistryRemaps.replacement(id(registry), id(from))); } - private static ResourceLocation id(String name) { return ResourceLocation.fromNamespaceAndPath("minecraft", name); } + private static Identifier id(String name) { return Identifier.fromNamespaceAndPath("minecraft", name); } } diff --git a/src/test/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPackTest.java b/src/test/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPackTest.java index d36c6e05..2572d25f 100644 --- a/src/test/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPackTest.java +++ b/src/test/java/zone/moddev/mc/basepower/recipe/MaterialPolicyPackTest.java @@ -6,7 +6,7 @@ import java.nio.charset.StandardCharsets; import java.util.HashSet; import java.util.Set; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.server.packs.PackType; import org.junit.Test; import static org.junit.Assert.*; @@ -41,24 +41,24 @@ public class MaterialPolicyPackTest { @Test public void policyOnlySuppliesServerDataAndHonoursResourceDiscovery() { MaterialPolicyPack pack = new MaterialPolicyPack(true, false); - ResourceLocation rubber = ResourceLocation.fromNamespaceAndPath("forge", "tags/item/rubber.json"); + Identifier rubber = Identifier.fromNamespaceAndPath("forge", "tags/item/rubber.json"); assertNotNull(pack.getResource(PackType.SERVER_DATA, rubber)); assertNull(pack.getResource(PackType.CLIENT_RESOURCES, rubber)); - assertEquals(Set.of(rubber, ResourceLocation.parse("forge:tags/item/ingots/rubber.json"), - ResourceLocation.parse("forge:tags/item/solder.json")), list(pack, PackType.SERVER_DATA, "forge")); + assertEquals(Set.of(rubber, Identifier.parse("forge:tags/item/ingots/rubber.json"), + Identifier.parse("forge:tags/item/solder.json")), list(pack, PackType.SERVER_DATA, "forge")); assertTrue(list(pack, PackType.SERVER_DATA, "basepower").isEmpty()); assertTrue(list(pack, PackType.CLIENT_RESOURCES, "forge").isEmpty()); } - private static Set list(MaterialPolicyPack pack, PackType type, String namespace) { - Set entries = new HashSet<>(); + private static Set list(MaterialPolicyPack pack, PackType type, String namespace) { + Set entries = new HashSet<>(); pack.listResources(type, namespace, "tags/item", (id, supplier) -> entries.add(id)); return entries; } private static JsonObject read(MaterialPolicyPack pack, String path) throws Exception { try (InputStream stream = pack.getResource(PackType.SERVER_DATA, - ResourceLocation.fromNamespaceAndPath("forge", "tags/item/" + path + ".json")).get()) { + Identifier.fromNamespaceAndPath("forge", "tags/item/" + path + ".json")).get()) { return new JsonParser().parse(new String(stream.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject(); } } diff --git a/src/test/java/zone/moddev/mc/basepower/recipe/MaterialTagsTest.java b/src/test/java/zone/moddev/mc/basepower/recipe/MaterialTagsTest.java index d3ec729a..848ed7d0 100644 --- a/src/test/java/zone/moddev/mc/basepower/recipe/MaterialTagsTest.java +++ b/src/test/java/zone/moddev/mc/basepower/recipe/MaterialTagsTest.java @@ -9,7 +9,7 @@ import java.util.Set; import net.minecraft.world.item.Item; import net.minecraft.world.item.Items; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.server.packs.PackType; import org.junit.BeforeClass; import org.junit.Test; @@ -35,7 +35,7 @@ public class MaterialTagsTest { } private static Set resolve(MaterialPolicyPack pack, String path, String userEntries) throws Exception { - var resource = ResourceLocation.fromNamespaceAndPath("forge", "tags/item/" + path + ".json"); + var resource = Identifier.fromNamespaceAndPath("forge", "tags/item/" + path + ".json"); JsonObject policy; try (var reader = new InputStreamReader(pack.getResource(PackType.SERVER_DATA, resource).get(), StandardCharsets.UTF_8)) { diff --git a/src/test/java/zone/moddev/mc/basepower/recipe/PartsLootTest.java b/src/test/java/zone/moddev/mc/basepower/recipe/PartsLootTest.java index 60e4ef6a..17935013 100644 --- a/src/test/java/zone/moddev/mc/basepower/recipe/PartsLootTest.java +++ b/src/test/java/zone/moddev/mc/basepower/recipe/PartsLootTest.java @@ -1,7 +1,7 @@ package zone.moddev.mc.basepower.recipe; import java.util.List; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import org.junit.Test; import static org.junit.Assert.*; @@ -25,6 +25,6 @@ public class PartsLootTest { } } - private static ResourceLocation parts(String mod) { return id(mod + ":chests/advantage_parts"); } - private static ResourceLocation id(String value) { return ResourceLocation.parse(value); } + private static Identifier parts(String mod) { return id(mod + ":chests/advantage_parts"); } + private static Identifier id(String value) { return Identifier.parse(value); } } diff --git a/src/test/java/zone/moddev/mc/basepower/registry/MachineGuiTitleTest.java b/src/test/java/zone/moddev/mc/basepower/registry/MachineGuiTitleTest.java index de01ac93..32cfc42b 100644 --- a/src/test/java/zone/moddev/mc/basepower/registry/MachineGuiTitleTest.java +++ b/src/test/java/zone/moddev/mc/basepower/registry/MachineGuiTitleTest.java @@ -24,8 +24,9 @@ public class MachineGuiTitleTest { ChestBlockEntity tile = new ChestBlockEntity(net.minecraft.core.BlockPos.ZERO, Blocks.CHEST.defaultBlockState()); var registries = net.minecraft.core.RegistryAccess.fromRegistryOfRegistries(net.minecraft.core.registries.BuiltInRegistries.REGISTRY); var tag = new net.minecraft.nbt.CompoundTag(); - tag.putString("CustomName", Component.Serializer.toJson(Component.literal("John's oil tank"), registries)); - tile.loadWithComponents(tag, registries); + tag.store("CustomName", net.minecraft.network.chat.ComponentSerialization.CODEC, Component.literal("John's oil tank")); + tile.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create( + net.minecraft.util.ProblemReporter.DISCARDING, registries, tag)); assertEquals("John's oil tank", MachineGUIRegistry.menuTitle(tile, Blocks.CHEST).getString()); } 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 11307d16..6c5c0790 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/BuildWorkflowContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/BuildWorkflowContractTest.java @@ -7,13 +7,63 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Properties; +import java.util.HashSet; +import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Stream; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; public class BuildWorkflowContractTest { + @Test public void everySourceWorkflowTargetsTheDeclaredMinecraftBranch() throws Exception { + Properties pins = new Properties(); + try (var input = Files.newBufferedReader(Paths.get("gradle.properties"), StandardCharsets.UTF_8)) { + pins.load(input); + } + String branch = "master-" + pins.getProperty("minecraft_version"); + String valid = "on:\n push:\n branches: [" + branch + + "]\n pull_request:\n branches:\n - " + branch + "\npermissions:\n contents: read\n"; + requireWorkflowTargets(valid, branch); + assertThrows(AssertionError.class, () -> requireWorkflowTargets( + valid.replace(" - " + branch, " - " + branch + "-wrong # " + branch), branch)); + assertThrows(AssertionError.class, () -> requireWorkflowTargets( + valid.replace(" pull_request:", " unrelated:"), branch)); + for (String name : new String[] {"ci.yml", "codeql-analysis.yml", "validate-gradle-build.yml"}) { + String workflow = Files.readString(Paths.get(".github/workflows", name), StandardCharsets.UTF_8); + requireWorkflowTargets(workflow, branch); + } + } + + private static void requireWorkflowTargets(String workflow, String branch) { + var events = Pattern.compile("(?ms)^ (push|pull_request):[ \\t]*\\R(.*?)(?=^ [A-Za-z_][A-Za-z_0-9]*:|^\\S|\\z)") + .matcher(workflow); + Set found = new HashSet<>(); + Pattern reference = Pattern.compile(Pattern.quote(branch) + "(?![\\w.-])"); + while (events.find()) { + String body = events.group(2).replaceAll("(?m)#.*$", ""); + assertTrue(events.group(1) + " must target " + branch, reference.matcher(body).find()); + found.add(events.group(1)); + } + assertEquals(Set.of("push", "pull_request"), found); + } + + @Test public void candidateUploadFollowsTheAuditedVersionInsteadOfAHardCodedFilename() throws Exception { + String workflow = Files.readString(Paths.get(".github/workflows/ci.yml"), StandardCharsets.UTF_8); + Pattern hardCodedJar = Pattern.compile("build/libs/basePower-[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+"); + assertTrue(hardCodedJar.matcher("build/libs/basePower-2.4.14.121111.jar").find()); + assertFalse("A version bump must not leave the candidate upload pointing at old jars", + hardCodedJar.matcher(workflow).find()); + assertTrue(workflow.contains("build/libs/basePower-*.jar")); + assertTrue(workflow.contains("if-no-files-found: error")); + int audit = workflow.indexOf("- name: Build and audit release artifacts"); + int upload = workflow.indexOf("- name: Upload audited release candidate"); + assertTrue(audit >= 0 && upload > audit); + assertTrue(workflow.contains("verifyReleaseArtifacts")); + } + @Test public void testFixturesDoNotImportRestrictedUnsafeTypes() throws Exception { Pattern restrictedImport = Pattern.compile("(?m)^\\s*import\\s+(?:static\\s+)?sun\\.misc\\.Unsafe\\b"); assertTrue(restrictedImport.matcher("import sun.misc.Unsafe;").find()); @@ -45,7 +95,7 @@ public class BuildWorkflowContractTest { assertTrue(name, workflow.contains("(cd \"$basemetals\" && bash gradle/stage-orespawn-release.sh \"$mirror\")")); 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") + ".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_HOME_16_X64")); assertTrue(name, workflow.contains("-Porg.gradle.java.installations.paths=")); 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 7a836406..d6934523 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/ClientResourceContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/ClientResourceContractTest.java @@ -51,10 +51,55 @@ public class ClientResourceContractTest { } } - @Test public void oilBucketsAndHiddenFluidItemsHaveModels() { + @Test public void oilBucketsAndHiddenFluidItemsHaveModels() throws IOException { for (String id : new String[] {"crude_oil", "refined_oil", "crude_oil_bucket", "refined_oil_bucket"}) { assertTrue("Missing item model for " + id, Files.isRegularFile(ASSETS.resolve("models/item/" + id + ".json"))); } + List models = jsonFiles(ASSETS.resolve("models/item")); + List definitions = jsonFiles(ASSETS.resolve("items")); + assertEquals("Every preserved item model needs a native item definition", 35, models.size()); + assertEquals("Native item definitions must cover aliases and hidden items too", models.size(), definitions.size()); + for (Path model : models) { + String name = model.getFileName().toString(); + Path file = ASSETS.resolve("items").resolve(name); + JsonObject definition = read(file); + assertEquals(file + " must retain the native model node", 1, definition.size()); + JsonObject node = definition.getAsJsonObject("model"); + assertEquals(file + " must not redirect or conditionally hide the old model", 2, node.size()); + assertEquals(file.toString(), "minecraft:model", node.get("type").getAsString()); + 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()); + String build = Files.readString(Paths.get("build.gradle"), StandardCharsets.UTF_8); + assertTrue("Combined Eclipse defaults must select the native 1.21.11 profile", + 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")); + assertEquals("development", profile.get("mode").getAsString()); + assertEquals("1.21.11", profile.get("minecraftVersion").getAsString()); + assertEquals("61.1.0", 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); + } + assertEquals("The default development profile must pin the current Power player bytes", + pins.getProperty("release_player_sha256"), profile.get("powerDeobfSha256").getAsString()); + var expansions = profile.getAsJsonArray("mods").asList().stream().map(JsonElement::getAsJsonObject).toList(); + assertEquals(java.util.Set.of("basesteam", "baseelectric"), + expansions.stream().map(mod -> mod.get("id").getAsString()).collect(Collectors.toSet())); + assertEquals("No duplicate or test-only runtime candidate in the production profile", 2, expansions.size()); + for (JsonObject mod : expansions) { + assertTrue("Missing exact expansion checksum", mod.get("sha256").getAsString().matches("[A-Fa-f0-9]{64}")); + assertEquals(pins.getProperty(mod.get("id").getAsString() + "_version"), mod.get("version").getAsString()); + } } @Test public void everyLocaleRetainsLegacyNamesAndProvidesModernAliases() throws IOException { @@ -108,7 +153,7 @@ private static void verifyReference(String reference, String directory, String s private static JsonObject read(Path file) throws IOException { try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { - return new JsonParser().parse(reader).getAsJsonObject(); + return JsonParser.parseReader(reader).getAsJsonObject(); } } diff --git a/src/test/java/zone/moddev/mc/basepower/resources/CombinedDevelopmentProfileTest.java b/src/test/java/zone/moddev/mc/basepower/resources/CombinedDevelopmentProfileTest.java new file mode 100644 index 00000000..2d607101 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/resources/CombinedDevelopmentProfileTest.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.basepower.resources; + +import com.google.gson.JsonParser; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CombinedDevelopmentProfileTest { + @Test public void combinedLaunchPinsTheCurrentMinecraftAndReleaseMetadata() throws Exception { + Properties release = new Properties(); + try (var input = Files.newInputStream(Path.of("gradle.properties"))) { release.load(input); } + String minecraft = release.getProperty("minecraft_version"); + Path path = Path.of("test-rig/profiles/native" + minecraft.replace(".", ""), "dev-core.json"); + var profile = JsonParser.parseString(Files.readString(path)).getAsJsonObject(); + assertEquals("development", profile.get("mode").getAsString()); + assertEquals(minecraft, profile.get("minecraftVersion").getAsString()); + assertEquals(release.getProperty("forge_version"), profile.get("forgeVersion").getAsString()); + assertEquals(release.getProperty("release_player_sha256"), profile.get("powerDeobfSha256").getAsString()); + assertEquals(2, profile.getAsJsonArray("mods").size()); + var seen = new java.util.HashSet(); + for (var element : profile.getAsJsonArray("mods")) { + var mod = element.getAsJsonObject(); + String id = mod.get("id").getAsString(); + assertTrue(seen.add(id)); + assertEquals(release.getProperty(id + "_version"), mod.get("version").getAsString()); + assertTrue(mod.get("sha256").getAsString().matches("[A-F0-9]{64}")); + } + assertEquals(java.util.Set.of("basesteam", "baseelectric"), seen); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/resources/CreativeTabInitializationTest.java b/src/test/java/zone/moddev/mc/basepower/resources/CreativeTabInitializationTest.java index 76933538..6e327256 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/CreativeTabInitializationTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/CreativeTabInitializationTest.java @@ -9,7 +9,7 @@ import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.contents.TranslatableContents; import net.minecraft.resources.ResourceKey; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.Item; import net.minecraft.world.item.Items; @@ -40,10 +40,10 @@ public class CreativeTabInitializationTest { event.newInstance(Registries.CREATIVE_MODE_TAB, null, registry)); ItemGroups.show(Items.IRON_INGOT); ItemGroups.show(Items.STICK); - var id = ResourceLocation.parse("basepower:machines"); + var id = Identifier.parse("basepower:machines"); assertEquals(Set.of(id), registry.keySet()); var tab = ItemGroups.tab_powerAdvantage; - assertSame(tab, registry.get(id)); + assertSame(tab, registry.get(id).orElseThrow().value()); assertEquals("itemGroup.basepower", ((TranslatableContents) tab.getDisplayName().getContents()).getKey()); var field = CreativeModeTab.class.getDeclaredField("displayItemsGenerator"); field.setAccessible(true); diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativeBlockEntityFactoryTest.java b/src/test/java/zone/moddev/mc/basepower/resources/NativeBlockEntityFactoryTest.java index 40544005..4843f473 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/NativeBlockEntityFactoryTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativeBlockEntityFactoryTest.java @@ -28,7 +28,7 @@ public class NativeBlockEntityFactoryTest { BlockConveyor block = new BlockConveyor(0.75F); BlockEntityType old = Entities.CONVEYOR; try { - Entities.CONVEYOR = BlockEntityType.Builder.of(TileEntityConveyor::new, block).build(null); + Entities.CONVEYOR = new BlockEntityType<>(TileEntityConveyor::new, java.util.Set.of(block)); BlockPos position = new BlockPos(37, 83, -41); BlockState state = block.defaultBlockState().setValue(BlockConveyor.FACING, net.minecraft.core.Direction.UP); TileEntityConveyor placed = (TileEntityConveyor)block.newBlockEntity(position, state); @@ -40,8 +40,9 @@ public class NativeBlockEntityFactoryTest { assertNotNull(block.getTicker(null, state, Entities.CONVEYOR)); net.minecraft.nbt.CompoundTag saved = new net.minecraft.nbt.CompoundTag(); saved.putInt("x", 0); - tile.loadWithComponents(saved, - net.minecraft.core.RegistryAccess.fromRegistryOfRegistries(net.minecraft.core.registries.BuiltInRegistries.REGISTRY)); + tile.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create( + net.minecraft.util.ProblemReporter.DISCARDING, + net.minecraft.core.RegistryAccess.fromRegistryOfRegistries(net.minecraft.core.registries.BuiltInRegistries.REGISTRY), saved)); assertEquals("NBT cannot replace the native factory's final position", position, tile.getBlockPos()); } assertNull(block.getTicker(null, state, BlockEntityType.CHEST)); diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativeMachineDirtyStateTest.java b/src/test/java/zone/moddev/mc/basepower/resources/NativeMachineDirtyStateTest.java index cfd0530b..c0187054 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/NativeMachineDirtyStateTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativeMachineDirtyStateTest.java @@ -47,7 +47,7 @@ private int calls(MethodNode method, String owner, String name) { for (MethodNode method : production().methods) if (method.name.equals("markMachineStateUnsaved")) mark = method; assertNotNull("Missing native save invalidation", mark); assertEquals(1, calls(mark, "net/minecraft/server/level/ServerChunkCache", "getChunkNow")); - assertEquals(1, calls(mark, "net/minecraft/world/level/chunk/LevelChunk", "setUnsaved")); + assertEquals(1, calls(mark, "net/minecraft/world/level/chunk/LevelChunk", "markUnsaved")); for (AbstractInsnNode instruction : mark.instructions.toArray()) if (instruction instanceof MethodInsnNode) { String name = ((MethodInsnNode) instruction).name; assertFalse("Dirty marking must not load chunks or send comparator/client updates: " + name, diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativeMachinePropertyTest.java b/src/test/java/zone/moddev/mc/basepower/resources/NativeMachinePropertyTest.java index efb2cfaf..b793f104 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/NativeMachinePropertyTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativeMachinePropertyTest.java @@ -25,7 +25,8 @@ public class NativeMachinePropertyTest { } @Test public void machineDefaultsKeepPistonResistanceAndNonOccludingRendering() { - var state = new PropertyMachine(GUIBlock.machineProperties(0.75F)).defaultBlockState(); + var state = new PropertyMachine(zone.moddev.mc.basepower.init.Blocks.properties( + "property_test", GUIBlock.machineProperties(0.75F))).defaultBlockState(); state.initCache(); assertFalse(state.canOcclude()); assertTrue(state.blocksMotion()); @@ -33,20 +34,20 @@ public class NativeMachinePropertyTest { } @Test public void explicitNativePropertiesRetainBothSolidAndNonSolidChoices() { - assertTrue(new PropertyMachine(Block.Properties.of().mapColor(MapColor.STONE)).defaultBlockState().canOcclude()); - assertFalse(new PropertyMachine(Block.Properties.of().mapColor(MapColor.STONE).noOcclusion()) + assertTrue(new PropertyMachine(zone.moddev.mc.basepower.init.Blocks.properties("property_test", Block.Properties.of()).mapColor(MapColor.STONE)).defaultBlockState().canOcclude()); + assertFalse(new PropertyMachine(zone.moddev.mc.basepower.init.Blocks.properties("property_test", Block.Properties.of()).mapColor(MapColor.STONE).noOcclusion()) .defaultBlockState().canOcclude()); } @Test public void conveyorsRemainNonSolidWhenUsingExplicitNativeProperties() { assertFalse(new BlockConveyor(0.75F).defaultBlockState().canOcclude()); - assertFalse(new BlockConveyorFilter(0.75F, TileEntityConveyor.class) + assertFalse(new BlockConveyorFilter("test_filter", 0.75F, TileEntityConveyor.class) .defaultBlockState().canOcclude()); } @Test public void conduitSubclassesCanChooseNativePropertiesWithoutReplacingNetworkBehavior() { - PropertyConduit solid = new PropertyConduit(Block.Properties.of().mapColor(MapColor.STONE)); - PropertyConduit nonSolid = new PropertyConduit(Block.Properties.of().mapColor(MapColor.STONE).noOcclusion()); + PropertyConduit solid = new PropertyConduit(zone.moddev.mc.basepower.init.Blocks.properties("property_test", Block.Properties.of()).mapColor(MapColor.STONE)); + PropertyConduit nonSolid = new PropertyConduit(zone.moddev.mc.basepower.init.Blocks.properties("property_test", Block.Properties.of()).mapColor(MapColor.STONE).noOcclusion()); assertTrue(solid.defaultBlockState().canOcclude()); assertFalse(nonSolid.defaultBlockState().canOcclude()); assertEquals(6, nonSolid.defaultBlockState().getProperties().size()); diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativePackFormatContractTest.java b/src/test/java/zone/moddev/mc/basepower/resources/NativePackFormatContractTest.java new file mode 100644 index 00000000..6d1cb3cd --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativePackFormatContractTest.java @@ -0,0 +1,52 @@ +package zone.moddev.mc.basepower.resources; + +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 net.minecraft.server.packs.metadata.pack.PackMetadataSection; +import org.junit.Test; +import zone.moddev.mc.basepower.testing.NativePackResourceProof; +import static org.junit.Assert.*; + +public class NativePackFormatContractTest { + @org.junit.BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); net.minecraft.server.Bootstrap.bootStrap(); + } + + @Test public void nativeClientAndServerMetadataAcceptTheSameCombinedPack() throws Exception { + var proof = NativePackResourceProof.read(); + assertEquals(0, proof.getAsJsonArray("warningsAndErrors").size()); + var clientProof = proof.getAsJsonObject("CLIENT_RESOURCES"); + var serverProof = proof.getAsJsonObject("SERVER_DATA"); + assertEquals("mod_resources", clientProof.get("id").getAsString()); + assertEquals("net.minecraftforge.resource.DelegatingPackResources", clientProof.get("resourcesClass").getAsString()); + assertEquals("mod:basepower", serverProof.get("id").getAsString()); + assertEquals("net.minecraft.server.packs.PathPackResources", serverProof.get("resourcesClass").getAsString()); + assertEquals("COMPATIBLE", clientProof.get("currentCompatibility").getAsString()); + assertEquals("COMPATIBLE", serverProof.get("currentCompatibility").getAsString()); + assertTrue(clientProof.get("resourcesVerified").getAsInt() > 0); + assertTrue(serverProof.get("resourcesVerified").getAsInt() > 0); + var pack = JsonParser.parseString(Files.readString(Path.of("src/main/resources/pack.mcmeta"))) + .getAsJsonObject().getAsJsonObject("pack"); + assertFalse(pack.has("pack_format")); + assertFalse(pack.has("supported_formats")); + var client = PackMetadataSection.forPackType(PackType.CLIENT_RESOURCES).codec() + .parse(JsonOps.INSTANCE, clientProof.get("nativeMetadata")).getOrThrow(); + var server = PackMetadataSection.forPackType(PackType.SERVER_DATA).codec() + .parse(JsonOps.INSTANCE, serverProof.get("nativeMetadata")).getOrThrow(); + assertEquals(server, PackMetadataSection.forPackType(PackType.SERVER_DATA).codec() + .parse(JsonOps.INSTANCE, pack).getOrThrow()); + assertTrue(client.supportedFormats().isValueInRange(PackFormat.of( + 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))); + 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 new file mode 100644 index 00000000..0883853b --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativePackRangeContractTest.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.server.packs.PackType; +import net.minecraft.server.packs.metadata.pack.PackFormat; +import org.junit.Test; +import zone.moddev.mc.basepower.testing.NativePackResourceProof; +import static org.junit.Assert.*; + +public class NativePackRangeContractTest { + @Test public void combinedPackRangeAcceptsBothNativePackTypes() throws Exception { + var proof = NativePackResourceProof.read(); + assertEquals(0, proof.getAsJsonArray("warningsAndErrors").size()); + for (PackType type : PackType.values()) { + var side = proof.getAsJsonObject(type.name()); + 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))); + 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))); + } + } + + @Test public void omissionDoesNotSynthesizeMetadataInTheNativeModPathSupplier() throws Exception { + var root = Path.of("build/native-pack-resources").toAbsolutePath(); + Files.createDirectories(root); + var omitted = Files.createTempDirectory(root, "missing-metadata-"); + var info = new net.minecraft.server.packs.PackLocationInfo("mod:basepower", + net.minecraft.network.chat.Component.literal("basePower"), + net.minecraft.server.packs.repository.PackSource.DEFAULT, java.util.Optional.empty()); + var supplier = new net.minecraft.server.packs.PathPackResources.PathResourcesSupplier(omitted); + try (var resources = supplier.openPrimary(info)) { + assertNull(resources.getRootResource("pack.mcmeta")); + for (PackType type : PackType.values()) { + assertNull(type.toString(), resources.getMetadataSection( + net.minecraft.server.packs.metadata.pack.PackMetadataSection.forPackType(type))); + } + } + } + + @Test public void dataOnlyRangeDoesNotQualifyClientResources() { + JsonObject dataOnly = JsonParser.parseString("{\"min_format\":[94,1],\"max_format\":[94,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))); + } +} 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 35b66811..bd869611 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/NativePlatformContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativePlatformContractTest.java @@ -15,46 +15,47 @@ import static org.junit.Assert.*; public class NativePlatformContractTest { - @Test public void onlyPreDatafixMigrationStillUsesACoremod() throws Exception { - String json = new String(Files.readAllBytes(Paths.get("src/main/resources/META-INF/coremods.json")), StandardCharsets.UTF_8); - JsonObject coremods = new JsonParser().parse(json).getAsJsonObject(); - assertEquals(1, coremods.size()); - assertEquals("coremods/poweradvantage_118_migration.js", coremods.get("poweradvantage_118_migration").getAsString()); - for (String removed : new String[] {"oil_renderer", "material_tags", "parts_loot"}) - assertFalse(Files.exists(Paths.get("src/main/resources/coremods/poweradvantage_" + removed + ".js"))); + @Test public void allMigrationHooksUseRequiredNativeMixinsAndNoJavaScriptCoremod() throws Exception { + assertFalse(Files.exists(Paths.get("src/main/resources/META-INF/coremods.json"))); + JsonObject config = JsonParser.parseString(Files.readString(Paths.get("src/main/resources/basepower.mixins.json"))) + .getAsJsonObject(); + assertTrue(config.get("required").getAsBoolean()); + assertEquals("JAVA_21", config.get("compatibilityLevel").getAsString()); + 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", + "SimpleRegionStorageMixin", "WorldGenRegionMixin"), hooks); + for (String name : hooks) assertNotNull(read("zone/moddev/mc/basepower/mixin/" + name)); } - @Test public void pinnedForgeFiresTagEventAfterBindingNativeCollections() throws Exception { + @Test public void nativeRecipeAndAdvancementLoadingShareThePostponedTagContext() throws Exception { ClassNode node = read("net/minecraft/server/ReloadableServerResources"); - MethodNode bind = node.methods.stream().filter(method -> method.name.equals("updateRegistryTags") - && method.desc.contains("TagManager$LoadResult")) + MethodNode constructor = node.methods.stream().filter(method -> method.name.equals("")) .findFirst().orElseThrow(); - assertTrue(java.util.Arrays.stream(bind.instructions.toArray()).anyMatch(instruction -> - instruction instanceof MethodInsnNode call && call.owner.equals("net/minecraft/core/Registry") - && call.name.equals("bindTags"))); - int events = 0; - for (MethodNode method : node.methods) { - boolean installed = false; - for (AbstractInsnNode instruction : method.instructions.toArray()) { - if (instruction instanceof MethodInsnNode) { - MethodInsnNode call = (MethodInsnNode) instruction; - if (call.owner.equals("java/lang/Iterable") || call.owner.equals("java/util/List")) { - if (call.name.equals("forEach")) installed = true; - } + int contexts = 0, recipes = 0, advancements = 0; + for (AbstractInsnNode instruction : constructor.instructions.toArray()) { + if (instruction instanceof MethodInsnNode call && call.name.equals("")) { + if (call.owner.equals("net/minecraftforge/common/crafting/conditions/ConditionContext")) contexts++; + if (call.owner.equals("net/minecraft/world/item/crafting/RecipeManager")) { + assertEquals(1, contexts); + assertTrue(call.desc.contains("ICondition$IContext")); + recipes++; } - if (instruction instanceof MethodInsnNode call - && call.owner.equals("net/minecraftforge/event/ForgeEventFactory") - && call.name.equals("onTagsUpdated")) { - assertTrue("Bind tags before notifying observers", installed); - events++; + if (call.owner.equals("net/minecraft/server/ServerAdvancementManager")) { + assertEquals(1, contexts); + assertTrue(call.desc.contains("ICondition$IContext")); + advancements++; } } } - assertEquals(1, events); + assertEquals(1, contexts); + assertEquals(1, recipes); + assertEquals(1, advancements); } @Test public void nativeLootLoaderCallsForgeEventAndNotTheRemovedAdvantageBridge() throws Exception { - ClassNode node = read("net/minecraft/world/level/storage/loot/LootDataType"); + ClassNode node = read("net/minecraftforge/common/ForgeHooks"); int calls = 0; for (MethodNode method : node.methods) for (AbstractInsnNode instruction : method.instructions.toArray()) { if (instruction instanceof MethodInsnNode call) { diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativeTagReaderContractTest.java b/src/test/java/zone/moddev/mc/basepower/resources/NativeTagReaderContractTest.java index f78867cf..37be5080 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/NativeTagReaderContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativeTagReaderContractTest.java @@ -8,7 +8,7 @@ import java.nio.file.Paths; import net.minecraft.world.item.Item; import net.minecraft.world.item.Items; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import org.junit.BeforeClass; import org.junit.Test; import org.objectweb.asm.ClassReader; @@ -27,7 +27,7 @@ public class NativeTagReaderContractTest { assertFalse("Do not silently ignore an obsolete optional array", json.has("optional")); assertTrue(NativeTestTags.resolve(id -> null, id -> null, json).isEmpty()); var present = NativeTestTags.resolve(id -> - id.equals(ResourceLocation.parse("basemetals:steel_bars")) ? Items.IRON_BARS : null, id -> null, json); + id.equals(Identifier.parse("basemetals:steel_bars")) ? Items.IRON_BARS : null, id -> null, json); assertEquals(java.util.Set.of(Items.IRON_BARS), present); } diff --git a/src/test/java/zone/moddev/mc/basepower/resources/NativeTestTags.java b/src/test/java/zone/moddev/mc/basepower/resources/NativeTestTags.java index 444de0bf..a5fd1a97 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/NativeTestTags.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/NativeTestTags.java @@ -6,7 +6,7 @@ import java.util.LinkedHashSet; import java.util.Set; import java.util.function.Function; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.tags.TagEntry; import net.minecraft.tags.TagFile; @@ -15,12 +15,12 @@ public final class NativeTestTags { private NativeTestTags() {} - public static Set resolve(Function element, - Function> tag, JsonElement... files) { + public static Set resolve(Function element, + Function> tag, JsonElement... files) { Set values = new LinkedHashSet<>(); TagEntry.Lookup lookup = new TagEntry.Lookup<>() { - @Override public T element(ResourceLocation id) { return element.apply(id); } - @Override public Collection tag(ResourceLocation id) { return tag.apply(id); } + @Override public T element(Identifier id, boolean required) { return element.apply(id); } + @Override public Collection tag(Identifier id) { return tag.apply(id); } }; for (JsonElement json : files) { TagFile file = TagFile.CODEC.parse(JsonOps.INSTANCE, json) diff --git a/src/test/java/zone/moddev/mc/basepower/resources/RecipeRegistrationThreadTest.java b/src/test/java/zone/moddev/mc/basepower/resources/RecipeRegistrationThreadTest.java index 53e1f057..6218fdbc 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/RecipeRegistrationThreadTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/RecipeRegistrationThreadTest.java @@ -18,7 +18,7 @@ public class RecipeRegistrationThreadTest { if (instruction instanceof MethodInsnNode call && call.owner.equals(conditions) && call.name.equals("register")) { assertEquals("Subscribe during mod construction, before the registry event", "", method.name); - assertEquals("(Lnet/minecraftforge/eventbus/api/IEventBus;)V", call.desc); + assertEquals("(Lnet/minecraftforge/eventbus/api/bus/BusGroup;)V", call.desc); subscriptions++; } } @@ -29,7 +29,7 @@ public class RecipeRegistrationThreadTest { .findFirst().orElseThrow(); assertTrue(java.util.Arrays.stream(register.instructions.toArray()).anyMatch(instruction -> instruction instanceof MethodInsnNode call && call.owner.equals("net/minecraftforge/registries/DeferredRegister") - && call.name.equals("register") && call.desc.equals("(Lnet/minecraftforge/eventbus/api/IEventBus;)V"))); + && call.name.equals("register") && call.desc.equals("(Lnet/minecraftforge/eventbus/api/bus/BusGroup;)V"))); assertTrue(codecs.methods.stream().flatMap(method -> java.util.Arrays.stream(method.instructions.toArray())) .anyMatch(instruction -> instruction instanceof org.objectweb.asm.tree.FieldInsnNode field && field.owner.equals("net/minecraftforge/registries/ForgeRegistries$Keys") diff --git a/src/test/java/zone/moddev/mc/basepower/resources/RecipeUnlockContractTest.java b/src/test/java/zone/moddev/mc/basepower/resources/RecipeUnlockContractTest.java index a57b11ca..bce5ca7c 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/RecipeUnlockContractTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/RecipeUnlockContractTest.java @@ -131,7 +131,7 @@ private static String unlockPredicateKey(JsonObject predicate) { JsonObject predicate = criterion.getAsJsonObject("conditions").getAsJsonArray("items").get(0).getAsJsonObject(); var registries = net.minecraft.core.RegistryAccess.fromRegistryOfRegistries(net.minecraft.core.registries.BuiltInRegistries.REGISTRY); var ops = registries.createSerializationContext(com.mojang.serialization.JsonOps.INSTANCE); - var nativePredicate = net.minecraft.advancements.critereon.ItemPredicate.CODEC.parse(ops, predicate).getOrThrow(); + var nativePredicate = net.minecraft.advancements.criterion.ItemPredicate.CODEC.parse(ops, predicate).getOrThrow(); assertTrue(nativePredicate.test(new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.POTATO))); assertFalse("An unrelated dirt stack must not satisfy the real native predicate", nativePredicate.test(new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIRT))); @@ -139,7 +139,14 @@ private static String unlockPredicateKey(JsonObject predicate) { private static void collect(JsonElement element, List result) { if (element.isJsonArray()) for (JsonElement child : element.getAsJsonArray()) collect(child, result); - else result.add(element.getAsJsonObject()); + else if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String selector = element.getAsString(); + assertFalse("An empty native ingredient cannot unlock a recipe", selector.isBlank()); + JsonObject value = new JsonObject(); + value.addProperty(selector.startsWith("#") ? "tag" : "item", + selector.startsWith("#") ? selector.substring(1) : selector); + result.add(value); + } else result.add(element.getAsJsonObject()); } private static JsonObject read(Path file) throws IOException { diff --git a/src/test/java/zone/moddev/mc/basepower/resources/RequiredDependenciesTest.java b/src/test/java/zone/moddev/mc/basepower/resources/RequiredDependenciesTest.java new file mode 100644 index 00000000..c11a2730 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/resources/RequiredDependenciesTest.java @@ -0,0 +1,54 @@ +package zone.moddev.mc.basepower.resources; + +import com.google.gson.JsonObject; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Properties; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.maven.artifact.versioning.DefaultArtifactVersion; +import org.apache.maven.artifact.versioning.VersionRange; +import org.junit.Test; +import zone.moddev.mc.basepower.testing.NativePackResourceProof; +import static org.junit.Assert.*; + +public class RequiredDependenciesTest { + @Test public void nativeForgeRequiresBaseMetalsOnClientAndServer() throws Exception { + JsonObject dependency = dependency("basemetals"); + assertTrue("Base Metals is required, not an optional integration", dependency.get("mandatory").getAsBoolean()); + assertEquals("AFTER", dependency.get("ordering").getAsString()); + assertEquals("BOTH", dependency.get("side").getAsString()); + Properties pins = pins(); + VersionRange range = VersionRange.createFromVersionSpec(dependency.get("versionRange").getAsString()); + assertTrue(range.containsVersion(new DefaultArtifactVersion(pins.getProperty("basemetals_version")))); + assertFalse(range.containsVersion(new DefaultArtifactVersion("3.1.1.121111"))); + assertFalse(range.containsVersion(new DefaultArtifactVersion("4.0.0"))); + } + + @Test public void nativeRequiredDependenciesAgreeWithCurseForgeRequirements() throws Exception { + Set requirements = Arrays.stream(pins().getProperty("cf_requirements").split(",")) + .collect(Collectors.toSet()); + assertTrue(requirements.contains("base-metals")); + assertTrue(requirements.contains("mmd-orespawn")); + for (String id : new String[] {"basemetals", "orespawn", "forge", "minecraft"}) { + assertTrue(id, dependency(id).get("mandatory").getAsBoolean()); + } + } + + private static JsonObject dependency(String id) throws Exception { + var matches = NativePackResourceProof.read().getAsJsonArray("dependencies").asList().stream() + .map(element -> element.getAsJsonObject()) + .filter(entry -> id.equals(entry.get("modId").getAsString())).toList(); + assertEquals("Exactly one native dependency declaration for " + id, 1, matches.size()); + return matches.getFirst(); + } + + private static Properties pins() throws Exception { + Properties pins = new Properties(); + try (var reader = Files.newBufferedReader(Path.of("gradle.properties"))) { + pins.load(reader); + } + return pins; + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/resources/StarchRecipeTest.java b/src/test/java/zone/moddev/mc/basepower/resources/StarchRecipeTest.java index 087c79cc..50e07b00 100644 --- a/src/test/java/zone/moddev/mc/basepower/resources/StarchRecipeTest.java +++ b/src/test/java/zone/moddev/mc/basepower/resources/StarchRecipeTest.java @@ -40,7 +40,25 @@ private static Set potatoes() { } private static Set items(JsonArray values) { Set items = new HashSet<>(); - values.forEach(value -> items.add(value.getAsJsonObject().get("item").getAsString())); + values.forEach(value -> { + assertTrue("Forge 61 requires native string ingredient selectors", value.isJsonPrimitive() + && value.getAsJsonPrimitive().isString()); + items.add(value.getAsString()); + }); return items; } + + @Test public void nativeIngredientCodecAcceptsBothPotatoesAndRejectsDirt() throws Exception { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + zone.moddev.mc.advantageworks.unit.ForgeUnitRegistries.registerIngredientSerializers(); + var registries = net.minecraft.core.RegistryAccess.fromRegistryOfRegistries( + net.minecraft.core.registries.BuiltInRegistries.REGISTRY); + var ops = registries.createSerializationContext(com.mojang.serialization.JsonOps.INSTANCE); + var value = net.minecraft.world.item.crafting.Ingredient.CODEC.parse(ops, + read("starch_from_potato").getAsJsonArray("ingredients").get(0)).getOrThrow(); + assertTrue(value.test(new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.POTATO))); + assertTrue(value.test(new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.POISONOUS_POTATO))); + assertFalse(value.test(new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIRT))); + } } diff --git a/src/test/java/zone/moddev/mc/basepower/testing/NativePackProcessContractTest.java b/src/test/java/zone/moddev/mc/basepower/testing/NativePackProcessContractTest.java new file mode 100644 index 00000000..6aa6b453 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/testing/NativePackProcessContractTest.java @@ -0,0 +1,35 @@ +package zone.moddev.mc.basepower.testing; + +import java.nio.file.Path; +import org.junit.Test; +import static org.junit.Assert.*; + +public class NativePackProcessContractTest { + @Test public void redirectedChildUsesNonInteractiveTerminalWithoutDisablingLogging() { + Path root = Path.of("build", "native-pack-resources").toAbsolutePath(); + var process = NativePackResourceProof.proofProcess(Path.of("java"), "classpath", root, root, root.resolve("test.log")); + assertTrue(process.command().contains("-Dterminal.jline=false")); + assertEquals("dumb", process.environment().get("TERM")); + assertEquals(ProcessBuilder.Redirect.Type.WRITE, process.redirectOutput().type()); + assertTrue(process.redirectErrorStream()); + assertFalse(process.command().stream().anyMatch(argument -> argument.contains("log4j"))); + } + + @Test public void allWarningAndErrorFormsRemainFailures() { + for (String diagnostic : new String[] { + "2026-11-11T23:24:32Z main WARN Advanced terminal features are not available in this environment", + "[main/WARN] Resource pack metadata is invalid", + "[main/ERROR] Dependency discovery failed", "[main/FATAL] Cannot read resource pack"}) { + assertThrows(AssertionError.class, () -> NativePackResourceProof.requireCleanOutput(diagnostic, Path.of("proof.log"))); + } + NativePackResourceProof.requireCleanOutput("[main/INFO] Resources loaded", Path.of("proof.log")); + } + + @Test public void nativeProofActuallyRunsWithTerminalDisabled() throws Exception { + var proof = NativePackResourceProof.read(); + assertTrue(proof.get("interactiveTerminalDisabled").getAsBoolean()); + assertEquals(0, proof.getAsJsonArray("warningsAndErrors").size()); + assertTrue(proof.getAsJsonObject("CLIENT_RESOURCES").get("resourcesVerified").getAsInt() > 0); + assertTrue(proof.getAsJsonObject("SERVER_DATA").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 new file mode 100644 index 00000000..6aafd5cd --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/testing/NativePackResourceProof.java @@ -0,0 +1,211 @@ +package zone.moddev.mc.basepower.testing; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import cpw.mods.jarhandling.SecureJar; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.PathPackResources; +import net.minecraft.server.packs.metadata.pack.PackMetadataSection; +import net.minecraft.server.packs.repository.Pack; +import net.minecraft.server.packs.repository.PackRepository; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.resources.Identifier; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.loading.moddiscovery.ModFile; +import net.minecraftforge.fml.loading.moddiscovery.ModFileParser; +import net.minecraftforge.fml.loading.moddiscovery.ModInfo; +import net.minecraftforge.fml.loading.targets.ForgeDevLocator; +import net.minecraftforge.resource.ResourcePackLoader; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +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. */ +public final class NativePackResourceProof { + private static JsonObject receipt; + + private NativePackResourceProof() {} + + 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-"); + 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()) + .collect(java.util.stream.Collectors.joining(java.io.File.pathSeparator)); + Path javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java"); + if (!Files.isExecutable(javaExecutable)) javaExecutable = javaExecutable.resolveSibling("java.exe"); + Path log = evidence.resolve("forge-resources.log"); + Process process = proofProcess(javaExecutable, classpath, + Path.of("build/resources/main").toAbsolutePath(), evidence, log).start(); + if (!process.waitFor(90, TimeUnit.SECONDS)) { + process.destroyForcibly().waitFor(); + throw new AssertionError("Native Forge resource proof timed out: " + log); + } + String output = Files.readString(log); + if (process.exitValue() != 0) throw new AssertionError("Native Forge resource proof failed: " + log + "\n" + output); + requireCleanOutput(output, log); + receipt = JsonParser.parseString(Files.readString(evidence.resolve("receipt.json"))).getAsJsonObject(); + return receipt.deepCopy(); + } + + static ProcessBuilder proofProcess(Path javaExecutable, String classpath, Path resources, Path evidence, Path log) { + // The child has redirected output, not an interactive terminal. Disable only JLine, never diagnostics. + ProcessBuilder builder = new ProcessBuilder(javaExecutable.toString(), "-XX:ActiveProcessorCount=4", "-Xmx512m", + "-Dterminal.jline=false", "--add-opens", "java.base/java.lang.invoke=ALL-UNNAMED", "-cp", classpath, + NativePackResourceProof.class.getName(), resources.toString(), evidence.toString()) + .directory(evidence.toFile()).redirectErrorStream(true).redirectOutput(log.toFile()); + builder.environment().put("TERM", "dumb"); + return builder; + } + + static void requireCleanOutput(String output, Path log) { + if (java.util.regex.Pattern.compile("(?m)(?:\\[[^\\r\\n]*\\b(?:WARN|ERROR|FATAL)\\b[^\\r\\n]*\\]|\\bmain (?:WARN|ERROR|FATAL)\\b)") + .matcher(output).find()) { + throw new AssertionError("Native Forge resource diagnostic in " + log + "\n" + output); + } + } + + public static void main(String[] args) throws Exception { + if (!"false".equals(System.getProperty("terminal.jline"))) { + throw new AssertionError("The redirected proof child must not initialise an interactive terminal"); + } + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + Path resources = Path.of(args[0]); + Path evidence = Path.of(args[1]); + Path jar = evidence.resolve("basepower-resource-contract.jar"); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().putValue("Automatic-Module-Name", "basepower"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar), manifest); + var files = Files.walk(resources)) { + for (Path file : files.filter(Files::isRegularFile).sorted().toList()) { + output.putNextEntry(new JarEntry(resources.relativize(file).toString().replace('\\', '/'))); + Files.copy(file, output); + output.closeEntry(); + } + } + 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()); + List diagnostics = new ArrayList<>(); + AbstractAppender appender = new AbstractAppender("PowerPackContract", null, + PatternLayout.createDefaultLayout(), false, Property.EMPTY_ARRAY) { + @Override public void append(LogEvent event) { + if (event.getLevel().isMoreSpecificThan(Level.WARN)) diagnostics.add(event.getMessage().getFormattedMessage()); + } + }; + appender.start(); + var logger = (org.apache.logging.log4j.core.Logger)LogManager.getRootLogger(); + logger.addAppender(appender); + JsonObject result = new JsonObject(); + result.addProperty("interactiveTerminalDisabled", true); + JsonArray dependencies = new JsonArray(); + for (var dependency : mod.getModInfos().getFirst().getDependencies()) { + JsonObject entry = new JsonObject(); + entry.addProperty("modId", dependency.getModId()); + entry.addProperty("mandatory", dependency.isMandatory()); + entry.addProperty("versionRange", dependency.getVersionRange().toString()); + entry.addProperty("ordering", dependency.getOrdering().name()); + entry.addProperty("side", dependency.getSide().name()); + dependencies.add(entry); + } + result.add("dependencies", dependencies); + try { + for (PackType type : PackType.values()) { + PackRepository repository = new PackRepository(); + ResourcePackLoader.loadResourcePacks(repository, type == PackType.CLIENT_RESOURCES); + repository.reload(); + String id = type == PackType.CLIENT_RESOURCES ? "mod_resources" : "mod:basepower"; + if (repository.getAvailableIds().size() != 1 || !repository.isAvailable(id)) { + throw new AssertionError("Unexpected native packs: " + repository.getAvailableIds()); + } + Pack pack = repository.getPack(id); + if (pack == null) throw new AssertionError("Forge did not discover " + id); + JsonObject side = new JsonObject(); + side.addProperty("id", id); + side.addProperty("loaderCompatibility", pack.getCompatibility().name()); + try (var opened = pack.open()) { + PackMetadataSection metadata = opened.getMetadataSection(PackMetadataSection.forPackType(type)); + if (metadata == null) throw new AssertionError("Missing native " + type + " metadata"); + var formats = metadata.supportedFormats(); + var codec = PackMetadataSection.forPackType(type).codec(); + var encoded = codec.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, metadata).getOrThrow(); + var decoded = codec.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow(); + // Native JSON widens numeric translation arguments; compare the wire value and exact range. + if (!formats.equals(decoded.supportedFormats()) + || !encoded.equals(codec.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, decoded).getOrThrow())) { + throw new AssertionError("Native metadata round-trip changed " + type); + } + 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.add("nativeMetadata", encoded); + if (type == PackType.CLIENT_RESOURCES) { + if (opened.getChildren() == null || opened.getChildren().size() != 1) { + throw new AssertionError("Expected the actual Forge client delegate"); + } + side.addProperty("currentCompatibility", pack.getCompatibility().name()); + } else { + var supplier = new PathPackResources.PathResourcesSupplier(mod.findResource("")); + var current = Pack.readPackMetadata(pack.location(), supplier, + SharedConstants.getCurrentVersion().packVersion(type), type); + if (current == null) throw new AssertionError("Native current-version server metadata was rejected"); + side.addProperty("currentCompatibility", current.compatibility().name()); + } + } + repository.setSelected(List.of(id)); + int checked = 0; + try (MultiPackResourceManager manager = new MultiPackResourceManager(type, repository.openAllSelected()); + var files = Files.walk(resources.resolve(type.getDirectory()))) { + for (Path file : files.filter(Files::isRegularFile).sorted().toList()) { + Path relative = resources.resolve(type.getDirectory()).relativize(file); + String namespace = relative.getName(0).toString(); + String path = relative.subpath(1, relative.getNameCount()).toString().replace('\\', '/'); + var resource = manager.getResource(Identifier.fromNamespaceAndPath(namespace, path)).orElseThrow(); + try (var input = resource.open()) { + if (!Arrays.equals(Files.readAllBytes(file), input.readAllBytes())) { + throw new AssertionError("Forge changed " + type + " resource " + relative); + } + } + checked++; + } + } + if (checked == 0) throw new AssertionError("No native " + type + " resources were read"); + side.addProperty("resourcesVerified", checked); + result.add(type.name(), side); + } + } finally { + logger.removeAppender(appender); + appender.stop(); + } + JsonArray messages = new JsonArray(); + diagnostics.forEach(messages::add); + result.add("warningsAndErrors", messages); + Files.writeString(evidence.resolve("receipt.json"), result.toString(), StandardCharsets.UTF_8); + if (!diagnostics.isEmpty()) throw new AssertionError("Native Forge resource diagnostics: " + diagnostics); + System.out.println("BASEPOWER_NATIVE_PACK_RESOURCES_PASS " + result); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/util/ForgeFluidAdapterTest.java b/src/test/java/zone/moddev/mc/basepower/util/ForgeFluidAdapterTest.java index fbf679b3..a7b5ac81 100644 --- a/src/test/java/zone/moddev/mc/basepower/util/ForgeFluidAdapterTest.java +++ b/src/test/java/zone/moddev/mc/basepower/util/ForgeFluidAdapterTest.java @@ -38,7 +38,7 @@ public class ForgeFluidAdapterTest { assertEquals(tag, offered.getTag()); assertEquals(1, changes[0]); tank.tank.getFluid().tag.putString("Owner", "changed-in-tank"); - assertEquals("preserve-me", offered.getTag().getString("Owner")); + assertEquals("preserve-me", offered.getTag().getString("Owner").orElseThrow()); } @Test public void keepsSidedRestrictionsAndRejectsEmptyOrInvalidRequests() { @@ -80,8 +80,8 @@ public class ForgeFluidAdapterTest { net.minecraftforge.fluids.FluidStack nativeStack = ForgeFluidAdapter.toForge(source); FluidStack copy = ForgeFluidAdapter.fromForge(nativeStack); copy.tag.putInt("batch", 8); - assertEquals(7, source.tag.getInt("batch")); - assertEquals(7, nativeStack.getTag().getInt("batch")); + assertEquals(7, source.tag.getInt("batch").orElseThrow().intValue()); + assertEquals(7, nativeStack.getTag().getInt("batch").orElseThrow().intValue()); assertNull(ForgeFluidAdapter.fromForge(net.minecraftforge.fluids.FluidStack.EMPTY)); assertTrue(ForgeFluidAdapter.toForge(null).isEmpty()); } diff --git a/src/test/java/zone/moddev/mc/basepower/util/LegacyNbtFieldsTest.java b/src/test/java/zone/moddev/mc/basepower/util/LegacyNbtFieldsTest.java new file mode 100644 index 00000000..0ebacbcc --- /dev/null +++ b/src/test/java/zone/moddev/mc/basepower/util/LegacyNbtFieldsTest.java @@ -0,0 +1,46 @@ +package zone.moddev.mc.basepower.util; + +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.world.level.storage.TagValueOutput; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class LegacyNbtFieldsTest { + @Test + public void preservesFlatTypedFieldsAndOpaqueNestedData() { + CompoundTag expected = new CompoundTag(); + expected.putIntArray("Energy", new int[] {Float.floatToIntBits(12.5F)}); + expected.putByte("Facing", (byte) 4); + CompoundTag stack = new CompoundTag(); + stack.putString("id", "other:part"); + stack.putInt("count", 17); + CompoundTag custom = new CompoundTag(); + custom.putLong("Serial", Long.MAX_VALUE); + stack.put("components", custom); + ListTag items = new ListTag(); + items.add(stack); + expected.put("Items", items); + HolderLookup.Provider lookup = HolderLookup.Provider.create(java.util.stream.Stream.empty()); + var output = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, lookup); + LegacyNbtFields.write(output, expected); + CompoundTag actual = output.buildResult(); + assertEquals(expected, actual); + assertEquals(expected, LegacyNbtFields.read(TagValueInput.create(ProblemReporter.DISCARDING, lookup, actual))); + assertEquals(expected, actual); + assertSame(lookup, LegacyNbtFields.registries(output, null)); + } + + @Test + public void emptyFieldsDoNotCreateAnExtraWrapper() { + HolderLookup.Provider lookup = HolderLookup.Provider.create(java.util.stream.Stream.empty()); + var output = TagValueOutput.createWithoutContext(ProblemReporter.DISCARDING); + LegacyNbtFields.write(output, new CompoundTag()); + assertTrue(output.buildResult().isEmpty()); + assertSame(lookup, LegacyNbtFields.registries(output, lookup)); + } +} diff --git a/src/test/java/zone/moddev/mc/basepower/util/MachineCapabilitiesLifecycleTest.java b/src/test/java/zone/moddev/mc/basepower/util/MachineCapabilitiesLifecycleTest.java index a4c4c2b5..9081f648 100644 --- a/src/test/java/zone/moddev/mc/basepower/util/MachineCapabilitiesLifecycleTest.java +++ b/src/test/java/zone/moddev/mc/basepower/util/MachineCapabilitiesLifecycleTest.java @@ -35,8 +35,8 @@ public class MachineCapabilitiesLifecycleTest { assertNotNull(ForgeCapabilities.ENERGY); previousTank = Entities.METAL_TANK; previousConveyor = Entities.CONVEYOR; - Entities.METAL_TANK = BlockEntityType.Builder.of(MetalTankTileEntity::new, Blocks.IRON_BLOCK).build(null); - Entities.CONVEYOR = BlockEntityType.Builder.of(TileEntityConveyor::new, Blocks.HOPPER).build(null); + Entities.METAL_TANK = new BlockEntityType<>(MetalTankTileEntity::new, java.util.Set.of(Blocks.IRON_BLOCK)); + Entities.CONVEYOR = new BlockEntityType<>(TileEntityConveyor::new, java.util.Set.of(Blocks.HOPPER)); } @AfterClass public static void restoreNativeTypes() { @@ -157,7 +157,7 @@ private static final class TestTank extends MetalTankTileEntity { private static final class TestConverter extends cyano.poweradvantage.api.modsupport.rf.TileEntityRFConverter { private static final BlockEntityType TYPE = - BlockEntityType.Builder.of(TestConverter::new, Blocks.IRON_BLOCK).build(null); + new BlockEntityType<>(TestConverter::new, java.util.Set.of(Blocks.IRON_BLOCK)); TestConverter() { this(BlockPos.ZERO, Blocks.IRON_BLOCK.defaultBlockState()); } TestConverter(BlockPos pos, BlockState state) { super(TYPE, pos, state, new cyano.poweradvantage.api.ConduitType("test-power"), 100, 1000, "test"); diff --git a/test-rig/build.gradle b/test-rig/build.gradle index b5bfae6e..52814e7b 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.forge52MavenizerDefault = '../../../../MinecraftMineralogy/OS1211/OreSpawn/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar' -apply from: '../gradle/forge52-mavenizer.gradle' +ext.forge61MavenizerDefault = '../../../../MinecraftMineralogy/OS12111/OreSpawn/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar' +apply from: '../gradle/forge61-mavenizer.gradle' // Saves, pinned inputs and evidence are siblings, never Gradle cleanup targets. layout.buildDirectory = layout.projectDirectory.dir('build/gradle') @@ -70,7 +70,7 @@ repositories { } def powerCompileJar = file(project.findProperty('powerAdvantageReleaseJar') - ?: '../build/libs/basePower-2.4.14.121011.jar') + ?: '../build/libs/basePower-2.4.15.121111.jar') def exploratoryPowerSha = providers.gradleProperty('worksExploratoryPowerSha256') def powerCompileSha = exploratoryPowerSha.orElse(providers.gradleProperty('works_power_sha256')) @@ -81,6 +81,7 @@ dependencies { testImplementation sourceSets.runner.output testImplementation 'junit:junit:4.13.2' compileOnly files(powerCompileJar) + testRuntimeOnly files(powerCompileJar) } def compileUnitBootstrap = tasks.register('compileUnitBootstrap', JavaCompile) { @@ -106,7 +107,7 @@ tasks.register('verifyWorksCompileInputPin') { inputs.file(powerCompileJar) inputs.property('powerSha256', powerCompileSha.orElse('')) doLast { - if (!powerCompileJar.isFile()) throw new GradleException("Missing 1.21.1 Power compile input: ${powerCompileJar}") + if (!powerCompileJar.isFile()) throw new GradleException("Missing 1.21.11 Power compile input: ${powerCompileJar}") if (!(powerCompileSha.getOrElse('') ==~ /[A-Fa-f0-9]{64}/)) { throw new GradleException('Missing or invalid Power SHA-256 pin') } @@ -196,7 +197,7 @@ def registerWorksRunner = { String taskName, String profileName, boolean automat if (!automated) standardInput = System.in doFirst { JavaExec task -> String pin = providers.gradleProperty('worksProfileFile').orNull - if (!pin) throw new GradleException("Generate the pinned ${profileName} profile with migration/native1211.py profile and set -PworksProfileFile") + if (!pin) throw new GradleException("Generate the pinned ${profileName} profile with migration/native12111.py profile and set -PworksProfileFile") File profile = file(pin) if (new groovy.json.JsonSlurper().parse(profile).name != profileName) { throw new GradleException("Expected profile ${profileName}: ${profile}") @@ -218,7 +219,7 @@ def registerWorksRunner = { String taskName, String profileName, boolean automat } registerWorksRunner('runWorksDev', 'dev-core', false, - 'Builds and launches a disposable interactive 1.21.1 Advantage Works development server.') + 'Builds and launches a disposable interactive 1.21.11 Advantage Works development server.') registerWorksRunner('verifyWorksDev', 'dev-core', true, 'Generates, checks, restarts, and rechecks the disposable development works.') registerWorksRunner('verifyWorksTechProgression', 'dev-core-tech-progression', true, @@ -242,15 +243,15 @@ registerWorksRunner('verifyWorksWorldgenLegacyImport', 'worldgen-legacy-import', registerWorksRunner('runWorksPackagedCurrent', 'packaged-current', false, 'Launches the complete packaged works on localhost for manual inspection.') -tasks.register('verifyWorksNative1211Inputs', JavaExec) { +tasks.register('verifyWorksNative12111Inputs', JavaExec) { group = 'verification' - description = 'Checks 1.21.1 candidate identities and hashes without creating or opening a world.' + description = 'Checks 1.21.11 candidate identities and hashes without creating or 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 52 profile') + if (!pin) throw new GradleException('Set -PworksProfileFile to a generated Forge 61 profile') args '--profile', file(pin).absolutePath, '--validate-only' configurePreparedSeedArgs(task) } @@ -283,15 +284,15 @@ tasks.named('check') { def nativeControlClasspath = configurations.compileClasspath.minus(files(powerCompileJar)) def compileUnitSupport = tasks.register('compileUnitSupport', JavaCompile) { - source fileTree('unit-support') + source fileTree('unit-support') { exclude '**/EclipseUnitLauncher.java' } classpath = nativeControlClasspath destinationDirectory = layout.buildDirectory.dir('unit-support/classes') } -tasks.register('verifyForge52UnitBootstrap', JavaExec) { +tasks.register('verifyForge61UnitBootstrap', JavaExec) { group = 'verification' dependsOn compileUnitSupport, unitBootstrapJar classpath = files(compileUnitSupport.flatMap { it.destinationDirectory }) + nativeControlClasspath - mainClass = 'zone.moddev.mc.advantageworks.unit.Forge52UnitControl' + mainClass = 'zone.moddev.mc.advantageworks.unit.Forge61UnitControl' javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(21) } doFirst { jvmArgs "-javaagent:${unitBootstrapJar.get().archiveFile.get().asFile}" } } diff --git a/test-rig/client-resources/README.md b/test-rig/client-resources/README.md index 0331a8f3..6e82fdb3 100644 --- a/test-rig/client-resources/README.md +++ b/test-rig/client-resources/README.md @@ -1,26 +1,26 @@ # Native Client Resource Check -This disposable Minecraft 1.21.1 / Forge 52.1.0 client checks models, textures, +This disposable Minecraft 1.21.11 / Forge 61.1.0 client checks models, textures, translations, recipe-book cells and machine screens after Minecraft loads them. It registers no gameplay content, opens no world and closes when finished. The probe is excluded from release jars and ordinary Eclipse launches. -See [port status](../../docs/1.21.1/PORT-STATUS.md) for current qualification. +Compilation and worldless unit checks are separate from actual graphical qualification. Earlier-version results do not qualify this version. Use Java 21 for Gradle, compilation and the client and server runners. The -target is official 1.21.1 mappings with MCP `20240808.132146`. +target is official 1.21.11 mappings with MCP `20251223.124241`. ## Build And Run ```powershell ./gradlew.bat check clientResourceProbeJar verifyEclipseProductionClasspath prepareCombinedEclipseLaunch ./test-rig/client-resources/test-launcher.ps1 -./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -MachineGuiLayouts -ChildRendererGeometry -RecipeBookFixtures '/unlock' -PreflightOnly -./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -MachineGuiLayouts -ChildRendererGeometry -RecipeBookFixtures '/unlock' +./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -MachineGuiLayouts -ChildRendererGeometry -RecipeBookFixtures '/unlock' -PreflightOnly +./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -MachineGuiLayouts -ChildRendererGeometry -RecipeBookFixtures '/unlock' ``` -The combined launch requires Power `2.4.14.121011`, Steam `2.2.15.121011` and -Electric `2.2.15.121011`. Pin the exact rebuilt jars in `profiles/native1211` +The combined launch requires Power `2.4.15.121111`, Steam `2.2.16.121111` and +Electric `2.2.16.121111`. Pin the exact rebuilt jars in `profiles/native12111` before regenerating it. Omitting the combined profile retains Power-only scope. Normal Eclipse launches contain production code only. @@ -39,10 +39,10 @@ is provenance, not proof that the selected loose output equals that jar. ## Recipe Fixtures -Generate fresh exports from the packaged 1.21.1 candidates, not a historical run: +Generate fresh exports from the packaged 1.21.11 candidates, not a historical run: ```powershell - -B test-rig/migration/native1211_recipes.py --profile '' --seed '' --inventory '' --inventory-sha256 '' --java '/bin/java.exe' --port 25577 --output test-rig/build/native1211-recipes-new + -B test-rig/migration/native12111_recipes.py --profile '' --seed '' --inventory '' --inventory-sha256 '' --java '/bin/java.exe' --port 25577 --output test-rig/build/native12111-recipes-new ``` Use an existing Python 3.11+ environment with `migration/requirements.txt`. @@ -55,16 +55,19 @@ Unrelated errors still fail. Pass the resulting `unlock` directory to the client. It must contain the three `recipes-*-sweep.bin` packets, matching successful JSON reports with +`bookPacketType: ClientboundRecipeBookAddPacket`, per-recipe `displayIds`, `bookRecipes` and the generated world's `level.dat`. The client reads its registry snapshot before decoding the packets; it does not open the world. Historical or mismatched headers fail. Native recipe-book collections must group only identical outputs, retain every variant and render thirteen separate example cells with more than 1,000 colored framebuffer pixels. Without fixtures -this is explicitly `NOT_RUN_NO_NATIVE_FIXTURES`. +this is explicitly `NOT_RUN_NO_NATIVE_FIXTURES`. The removed full-holder update +packet is rejected, not emulated. Focused native packet/collection tests are in +`test/`; they are not graphical evidence or part of the production jar. ## What Is Checked -- Every registered Advantage item and valid block state: geometry, missing +- Every registered Advantage item and valid block state: native item definitions, geometry, missing models/sprites, English names, creative-tab contents and GUI translation keys. Native fluid-block models are intentionally particle-only. - Portable and metal tank gauges: compare the whole gauge to an independent @@ -81,6 +84,10 @@ this is explicitly `NOT_RUN_NO_NATIVE_FIXTURES`. buffers. Hydroturbine registration and texture are checked; its live orientation and spin still require a disposable world. +GUI captures use native `GuiRenderState` extraction, `GuiRenderer` GPU submission, +and fenced `Screenshot` readback from an actual temporary render target. There +is no legacy immediate-mode GL projection or synthetic pixel substitute. + The live probes retain the pixel, packet and missing-model checks. Run the current launcher's parser and negative controls without starting Minecraft: @@ -88,6 +95,43 @@ current launcher's parser and negative controls without starting Minecraft: ./test-rig/client-resources/test-launcher.ps1 -StaticOnly ``` +The native contract entry points retain the immutable historical source hashes +and caption fixture, compare 33 complete unchanged oracle methods against the +inherited 1.21.1 source with the JDK parser, and fingerprint the explicit native +API boundaries. The geometry contract can also bind GUI projection and GPU +readback to the actual Forge 61 mapped engine sources. Its mutation companion +rejects 31 weakened source mutants. None of these static results replaces a +graphical client run. + +After Gradle regenerates human Eclipse launches, run the preparation helper +with a fresh evidence directory, then run the full launcher test: + + ./test-rig/client-resources/prepare-eclipse-client.ps1 -Evidence + ./test-rig/client-resources/test-launcher.ps1 + +The helper validates all native asset objects and resolves only generated +asset arguments. It changes no source, root build logic or jar, launches no +client and opens no world. Reapply it after subsequent launch regeneration. + +## Frozen Candidate Runs + +Supply the frozen output mode, packaged profile and approved profile SHA-256, +compiled probe jar, complete native asset snapshot with index 29, and isolated +bootstrap cache. The runner snapshots and verifies the five production jars +without loading loose production output. The profile's server harness is not +loaded into this worldless client. + +The explicit loose-Power diagnostic switch instead snapshots Power's compiled +classes and resources while retaining frozen dependency jars. Its receipts +cannot qualify a frozen Power jar. Only diagnostic mode may stage old recipe +fixtures without tag packets so the recipe scope can fail independently while +other scopes finish; normal frozen runs reject incomplete exports. + +Fresh server exports must supply tagsPacketType, tagsPacketFile, +tagsPacketSha256 and itemTagCount. The client decodes the actual native tag +packet, verifies exact item-tag member IDs, and uses the native registry +tag-reload path before decoding the book display packet. + These checks do not certify machine operation, live animated entities, multiplayer menu handshakes, manual recipe discovery or third-party resource packs. Those remain separate from compilation and graphical qualification. diff --git a/test-rig/client-resources/contracts/NativeProbeContract.java b/test-rig/client-resources/contracts/NativeProbeContract.java new file mode 100644 index 00000000..fa385e62 --- /dev/null +++ b/test-rig/client-resources/contracts/NativeProbeContract.java @@ -0,0 +1,157 @@ +import com.sun.source.tree.ClassTree; +import com.sun.source.tree.CompilationUnitTree; +import com.sun.source.tree.MethodTree; +import com.sun.source.util.JavacTask; +import com.sun.source.util.TreePathScanner; +import com.sun.source.util.Trees; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.regex.Pattern; +import javax.tools.SimpleJavaFileObject; +import javax.tools.ToolProvider; + +/** Static native API-adapter drift checks, separate from the real client pixel tests. */ +public final class NativeProbeContract { + private static final String HISTORICAL = "c310d0ca63ad9a8408dd4bba75172afafa5c71b7"; + private static final String INHERITED = "398439074a60bf9b8f8872f92485cffc4aa96be9"; + private static final String PREFIX = "test-rig/client-resources/"; + private static final String JAVA = "java/zone/moddev/mc/advantageworks/client/"; + private static final Map HISTORICAL_HASHES = Map.of( + "ClientResourceProbe", "014E12FFFD8B1E52FF32FA55ABCD6A1708094AF00D85AFB2F1424D448E349453", + "TankVisualProbe", "666F6D8FC5F1B925665E019E4F38DAB90766A04CB64C9C256AD3BAC9BE6CF327", + "OilRenderProbe", "E99F2D29D58F5388406FB29F419D68FF02C3C9EB51A44018C9A3440E83F37777", + "RecipeBookGroupingProbe", "E30466B80CB0496E637B3C6AAC9D490EF55C8E0FE0EBC5ACAEF7EF90D8E9C1B1", + "MachineGuiLayoutProbe", "8D89215AEFFDDC96A7175AE84F315DFF009D6E0F0FC1942C986E626B5806AFFF", + "ChildRendererGeometryProbe", "B56A5F683FCC1312D3B749BEC11C57099ECB73E5C87C953EF55050EAE3320650"); + private static final Map INHERITED_HASHES = Map.of( + "ClientResourceProbe", "C8FDC4A003FE4749C8DC8D882CF1BD5B1E56F2BD235D83531F2B6D651037E4ED", + "TankVisualProbe", "2725D39B037F73347C9CFBAB5A0621A004754A3BDB1B6B66CD7CF9DEB2F66A75", + "OilRenderProbe", "7D444AD5B6A2B47BAB208807AB57BE2B1740E8014D1A0D299497002782316AA1", + "RecipeBookGroupingProbe", "A4ED7890B461612FDECD415DB53C45FF2557B379CFFF0BFCB02A7C4022618AFF", + "MachineGuiLayoutProbe", "5A034F4A09AE1FC5CFD6BBB6AB27088BE2317CE64C6783D5B409A130E3EB6096", + "ChildRendererGeometryProbe", "389DF44FFDF8AE897849D5492A1818C2710E7EDB9F1C5ACA47518E7D420E1C1C"); + private static final Map> UNCHANGED = Map.of( + "ClientResourceProbe", List.of("included", "verifyNetworkBootstrap", "require"), + "TankVisualProbe", List.of("verify", "registeredDefinition", "syncTank", "compare", "require"), + "OilRenderProbe", List.of("requireBucket", "blockAt", "require"), + "MachineGuiLayoutProbe", List.of("verifySlots", "populate", "labelError", "verifyInventoryRows", + "verifyNoTextOverArtwork", "verifyMissingLabelControls", "verifyHelperLimits", "require"), + "ChildRendererGeometryProbe", List.of("steam", "drill", "turret", "mesh", "beam", "valueOfType", + "set", "different", "ranges", "min", "max", "run", "close", "require")); + private static final Pattern TOKENS = Pattern.compile("\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|/\\*[\\s\\S]*?\\*/|//[^\\r\\n]*|\\s+"); + private static int checks; + + public static void main(String[] args) throws Exception { + if (args.length != 4) throw new IllegalArgumentException("repository target rules inventory|verify"); + require(canonical("alpha /* comment */ beta \"space kept\" ' ' // tail\n gamma") + .equals("alphabeta\"space kept\"' 'gamma"), "Canonical source lexer changed literal/comment boundaries"); + require(!canonical("\"a b\"").equals(canonical("\"ab\"")), "Canonical source lexer erased a string literal space"); + Path repository = Path.of(args[0]), target = Path.of(args[1]); + Properties rules = new Properties(); + try (var reader = Files.newBufferedReader(Path.of(args[2]), StandardCharsets.UTF_8)) { rules.load(reader); } + boolean inventory = args[3].equals("inventory"); + Map inherited = new LinkedHashMap<>(); + for (String name : INHERITED_HASHES.keySet().stream().sorted().toList()) { + reference(repository, HISTORICAL, JAVA + name + ".java", HISTORICAL_HASHES.get(name)); + inherited.put(name, reference(repository, INHERITED, JAVA + name + ".java", INHERITED_HASHES.get(name))); + } + int methods = 0; + for (String name : UNCHANGED.keySet().stream().sorted().toList()) { + Map before = methods(name, inherited.get(name)); + Map after = methods(name, Files.readString(target.resolve(JAVA + name + ".java"))); + for (String method : UNCHANGED.get(name)) { + String key = name + "." + method; + require(before.containsKey(key) && after.containsKey(key), "Missing unchanged oracle method: " + key); + require(canonical(before.get(key)).equals(canonical(after.get(key))), "Changed inherited oracle method: " + key); + methods++; + } + } + for (String name : List.of("ClientResourceProbe", "TankVisualProbe", "OilRenderProbe", "RecipeBookGroupingProbe", + "MachineGuiLayoutProbe", "ChildRendererGeometryProbe", "NativeGuiProjection")) { + String source = Files.readString(target.resolve(JAVA + name + ".java")); + String fingerprint = hash(canonical(source)); + if (inventory) System.out.println(name + "=" + fingerprint); + else require(fingerprint.equals(rules.getProperty(name)), name + " changed beyond explicit native adapters"); + } + String labels = reference(repository, HISTORICAL, "resources/gui-label-expectations.json", + "EC0B66B926053E90C216D856B5F33C19D5B7E92DBC4C9E2E25812B401AEBAC0E"); + labels = labels.replace("poweradvantage:", "basepower:").replace("steamadvantage:", "basesteam:") + .replace("electricadvantage:", "baseelectric:"); + require(labels.replace("\r\n", "\n").equals(Files.readString(target.resolve("resources/gui-label-expectations.json")).replace("\r\n", "\n")), + "Independent human-facing caption expectations changed"); + System.out.println("PASS: " + checks + " static native API-adapter checks; " + methods + + " complete oracle methods unchanged from SHA-bound inherited source. Historical hashes and captions retained. No Minecraft client was run."); + } + + private static String reference(Path root, String commit, String path, String expected) throws Exception { + Process process = new ProcessBuilder("git", "-C", root.toString(), "show", commit + ":" + PREFIX + path).start(); + String text = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String error = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + require(process.waitFor() == 0, "Cannot read immutable source " + commit + ":" + path + ": " + error); + require(hash(text).equals(expected), "Immutable source SHA-256 mismatch: " + commit + ":" + path); + return text; + } + + private static Map methods(String name, String source) throws Exception { + var compiler = ToolProvider.getSystemJavaCompiler(); + require(compiler != null, "The native source contract requires a full JDK"); + var file = new SimpleJavaFileObject(URI.create("string:///" + name + ".java"), javax.tools.JavaFileObject.Kind.SOURCE) { + @Override public CharSequence getCharContent(boolean ignored) { return source; } + }; + try (var manager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) { + JavacTask task = (JavacTask) compiler.getTask(null, manager, null, List.of("-proc:none", "--release", "21"), null, List.of(file)); + var positions = Trees.instance(task).getSourcePositions(); + Map result = new LinkedHashMap<>(); + for (CompilationUnitTree unit : task.parse()) new TreePathScanner() { + private final List owners = new ArrayList<>(); + @Override public Void visitClass(ClassTree tree, Void unused) { + owners.add(tree.getSimpleName().toString()); + super.visitClass(tree, unused); + owners.removeLast(); + return null; + } + @Override public Void visitMethod(MethodTree tree, Void unused) { + if (tree.getBody() != null) { + String key = String.join(".", owners) + "." + tree.getName(); + int start = (int) positions.getStartPosition(unit, tree.getBody()); + int end = (int) positions.getEndPosition(unit, tree.getBody()); + if (result.putIfAbsent(key, source.substring(start, end)) != null) result.put(key, "AMBIGUOUS_OVERLOAD"); + } + return super.visitMethod(tree, unused); + } + }.scan(unit, null); + return result; + } + } + + private static String canonical(String source) { + var matcher = TOKENS.matcher(source); + StringBuilder result = new StringBuilder(); + int previous = 0; + while (matcher.find()) { + result.append(source, previous, matcher.start()); + String token = matcher.group(); + if (token.startsWith("\"") || token.startsWith("'")) result.append(token); + previous = matcher.end(); + } + return result.append(source, previous, source.length()).toString(); + } + + private static String hash(String text) throws Exception { + return HexFormat.of().withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + checks++; + } +} diff --git a/test-rig/client-resources/contracts/native61-adapters.properties b/test-rig/client-resources/contracts/native61-adapters.properties new file mode 100644 index 00000000..8cd00f8d --- /dev/null +++ b/test-rig/client-resources/contracts/native61-adapters.properties @@ -0,0 +1,10 @@ +# Fixed native API adapter fingerprints. These do not replace the historical +# Git source hashes or the AST comparisons of unchanged pixel/geometry oracles. +# Filled only after the native adapter has been reviewed and compiled. +ClientResourceProbe=53C58551AB7C497D832BA83CF263E4DEFE72B55A67D96CCAE4492B5F2DD14E8C +TankVisualProbe=3E090666873873FDBF0340F49D1CB7C9F035439879F53D94AA8ED4ABF2C0A9FB +OilRenderProbe=25E79D5BAB3A5A8E12E3986944CF44BCEC411A0943C754E35CDB23BDE8545194 +RecipeBookGroupingProbe=CADF5E21F8070B12301FB4421E9BEC19A1B4779898E5E8AB40717A046036434B +MachineGuiLayoutProbe=472256113CC1E9DD8F8A13E924163D0613BE72190A691BBFD5746AFEC931C130 +ChildRendererGeometryProbe=38AC9544C171494E0CA628F9325763BAB90B80A5FEA8CDD2E5E24D595F68ABCD +NativeGuiProjection=F73EE23230435AE632338F1FA1622EF5F790B195A30E9B164CF2581A6DE1C1B4 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 2bce5b5b..6621fa2b 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 @@ -4,26 +4,21 @@ import com.google.gson.JsonObject; import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.VertexConsumer; -import com.mojang.datafixers.util.Pair; import cyano.poweradvantage.api.PoweredEntity; import net.minecraft.client.Minecraft; import com.mojang.blaze3d.vertex.BufferBuilder; import net.minecraft.client.renderer.MultiBufferSource; -import net.minecraft.client.renderer.RenderStateShard; -import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.rendertype.RenderType; import net.minecraft.client.renderer.entity.EntityRenderer; -import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; -import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher; import com.mojang.blaze3d.vertex.DefaultVertexFormat; import com.mojang.blaze3d.vertex.VertexFormat; import com.mojang.blaze3d.vertex.VertexFormatElement; import net.minecraft.world.entity.EntityType; -import net.minecraft.server.packs.resources.Resource; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.core.Direction; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.registries.ForgeRegistries; import javax.imageio.ImageIO; @@ -39,12 +34,10 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; /** Additional CPU geometry evidence from the registered renderers; never opens a world. */ final class ChildRendererGeometryProbe { private static final float EPSILON = 0.0001F; - private static final int LIGHT = 0xF000F0; private static final String STEAM_RENDERER = "zone.moddev.mc.basesteam.graphics.DrillBitRenderer"; private static final String DRILL_RENDERER = "zone.moddev.mc.baseelectric.graphics.LaserDrillRenderer"; private static final String TURRET_RENDERER = "zone.moddev.mc.baseelectric.graphics.LaserTurretRenderer"; @@ -63,13 +56,13 @@ static void verify(Minecraft minecraft, JsonObject result) { drill(minecraft, cases); turret(minecraft, cases); run(cases, "hydroturbine-registration", row -> { - EntityType type = ForgeRegistries.ENTITY_TYPES.getValue(ResourceLocation.parse("baseelectric:hydroturbine")); + EntityType type = ForgeRegistries.ENTITY_TYPES.getValue(Identifier.parse("baseelectric:hydroturbine")); require(type != null, "Missing registered hydroturbine type"); - EntityRenderer renderer = minecraft.getEntityRenderDispatcher().renderers.get(type); + EntityRenderer renderer = minecraft.getEntityRenderDispatcher().renderers.get(type); require(renderer != null && TURBINE_RENDERER.equals(renderer.getClass().getName()), "Missing production hydroturbine renderer"); row.addProperty("renderer", renderer.getClass().getName()); - ResourceLocation texture = (ResourceLocation) valueOfType(renderer, ResourceLocation.class); - require(ResourceLocation.parse("baseelectric:textures/entity/hydroturbine.png").equals(texture), "Wrong hydroturbine texture"); + Identifier texture = (Identifier) valueOfType(renderer, Identifier.class); + require(Identifier.parse("baseelectric:textures/entity/hydroturbine.png").equals(texture), "Wrong hydroturbine texture"); texture(minecraft, texture, row); row.addProperty("status", "MANUAL_GAP_WORLD_REQUIRED"); row.addProperty("gap", "HydroturbineEntity constructor reads World.rand; orientation/spin vertices require a fresh disposable client world. No entity was constructed, no render invoked, and no geometry pass is claimed."); @@ -175,7 +168,7 @@ && close(min(vertices, 1), 0.5F) && close(max(vertices, 1), 1F) } private static BlockEntity tile(String id) { - BlockEntityType type = ForgeRegistries.BLOCK_ENTITY_TYPES.getValue(ResourceLocation.parse(id)); + BlockEntityType type = ForgeRegistries.BLOCK_ENTITY_TYPES.getValue(Identifier.parse(id)); require(type != null, "Missing registered tile type " + id); net.minecraft.world.level.block.state.BlockState state = ForgeRegistries.BLOCKS.getValues().stream() .map(net.minecraft.world.level.block.Block::defaultBlockState).filter(type::isValid).findFirst() @@ -187,7 +180,7 @@ private static BlockEntity tile(String id) { private static List render(Minecraft minecraft, BlockEntity tile, String expectedRenderer, float partial, String expectedTexture, JsonObject row) throws Exception { - BlockEntityRenderer renderer = minecraft.getBlockEntityRenderDispatcher().getRenderer(tile); + BlockEntityRenderer renderer = minecraft.getBlockEntityRenderDispatcher().getRenderer(tile); require(renderer != null && expectedRenderer.equals(renderer.getClass().getName()), "Dispatcher has no expected production renderer: " + expectedRenderer); row.addProperty("renderer", renderer.getClass().getName()); row.addProperty("partial_tick", partial); @@ -195,17 +188,31 @@ private static List render(Minecraft minecraft, BlockEntity tile, Strin PoseStack.Pose original = matrices.last(); org.joml.Matrix4f position = new org.joml.Matrix4f(original.pose()); org.joml.Matrix3f normal = new org.joml.Matrix3f(original.normal()); - Capture buffers = new Capture(minecraft, ResourceLocation.parse(expectedTexture), row); + Capture buffers = new Capture(minecraft, Identifier.parse(expectedTexture), row); boolean balanced; Throwable renderFailure = null; try { - renderer.render(tile, partial, matrices, buffers, LIGHT, OverlayTexture.NO_OVERLAY); + var state = renderer.createRenderState(); + renderer.extractRenderState(tile, state, partial, net.minecraft.world.phys.Vec3.ZERO, null); + var collector = (net.minecraft.client.renderer.SubmitNodeCollector) java.lang.reflect.Proxy.newProxyInstance( + net.minecraft.client.renderer.SubmitNodeCollector.class.getClassLoader(), + new Class[]{net.minecraft.client.renderer.SubmitNodeCollector.class}, (proxy, method, arguments) -> { + if (method.getName().equals("order")) return proxy; + if (!method.getName().equals("submitCustomGeometry")) + throw new IllegalStateException("Unexpected native renderer submission: " + method); + PoseStack submitted = (PoseStack) arguments[0]; + RenderType pipeline = (RenderType) arguments[1]; + var geometry = (net.minecraft.client.renderer.SubmitNodeCollector.CustomGeometryRenderer) arguments[2]; + geometry.render(submitted.last(), buffers.getBuffer(pipeline)); + return null; + }); + renderer.submit(state, matrices, collector, new net.minecraft.client.renderer.state.CameraRenderState()); } catch (RuntimeException | LinkageError | AssertionError failure) { renderFailure = failure; row.addProperty("render_error", failure.toString()); throw failure; } finally { - balanced = matrices.clear() && matrices.last() == original + balanced = matrices.isEmpty() && matrices.last() == original && position.equals(original.pose()) && normal.equals(original.normal()); row.addProperty("matrix_balanced", balanced); try { buffers.finish(); } @@ -223,13 +230,13 @@ private static List render(Minecraft minecraft, BlockEntity tile, Strin private static final class Capture implements MultiBufferSource { private final Minecraft minecraft; - private final ResourceLocation expectedTexture; + private final Identifier expectedTexture; private final JsonArray batches = new JsonArray(); private final Map buffers = new LinkedHashMap<>(); private final List storage = new ArrayList<>(); private final List vertices = new ArrayList<>(); - Capture(Minecraft minecraft, ResourceLocation expectedTexture, JsonObject row) { + Capture(Minecraft minecraft, Identifier expectedTexture, JsonObject row) { this.minecraft = minecraft; this.expectedTexture = expectedTexture; row.add("native_buffers", batches); @@ -280,11 +287,12 @@ void finish() throws Exception { raw.add(values); } // Inspect the texture actually selected by the production RenderType, not a substitute type. - Object state = valueOfType(entry.getKey(), RenderType.CompositeState.class); - Object textureState = valueOfType(state, RenderStateShard.EmptyTextureStateShard.class); - Optional selected = (Optional) valueOfType(textureState, Optional.class); - require(selected.isPresent() && selected.get() instanceof ResourceLocation, "Production render type has no texture"); - ResourceLocation selectedTexture = (ResourceLocation) selected.get(); + Object state = valueOfType(entry.getKey(), net.minecraft.client.renderer.rendertype.RenderSetup.class); + Field texturesField = state.getClass().getDeclaredField("textures"); + texturesField.setAccessible(true); + Object binding = ((Map) texturesField.get(state)).get("Sampler0"); + require(binding != null, "Production render type has no texture"); + Identifier selectedTexture = (Identifier) valueOfType(binding, Identifier.class); batch.addProperty("texture", selectedTexture.toString()); require(expectedTexture.equals(selectedTexture), "Production renderer selected the wrong texture"); texture(minecraft, selectedTexture, batch); @@ -325,7 +333,7 @@ private static void beam(List vertices, Direction facing, float[] origi require(close(start, 0) && close(end, length), "Beam cardinal bounds/length changed"); } - private static void texture(Minecraft minecraft, ResourceLocation location, JsonObject row) throws IOException { + private static void texture(Minecraft minecraft, Identifier location, JsonObject row) throws IOException { try (var resource = minecraft.getResourceManager().open(location)) { BufferedImage image = ImageIO.read(resource); require(image != null && image.getWidth() > 0 && image.getHeight() > 0, "Renderer texture is not a readable image: " + location); 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 a7d21f87..a496e253 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 @@ -5,7 +5,8 @@ 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.resources.model.BakedModel; +import net.minecraft.client.renderer.block.model.BlockStateModel; +import net.minecraft.client.renderer.item.ItemStackRenderState; import net.minecraft.client.resources.language.I18n; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; @@ -13,11 +14,9 @@ import net.minecraft.core.RegistryAccess; import net.minecraft.world.flag.FeatureFlags; import net.minecraft.world.item.CreativeModeTab; -import net.minecraft.resources.ResourceLocation; -import net.minecraftforge.api.distmarker.Dist; +import net.minecraft.resources.Identifier; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.event.TickEvent; -import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.registries.ForgeRegistries; import org.apache.logging.log4j.LogManager; import zone.moddev.mc.basepower.init.ItemGroups; @@ -32,23 +31,26 @@ /** Runs after native resource baking, without opening or generating a world. */ @Mod(ClientResourceProbe.MODID) -@Mod.EventBusSubscriber(modid = ClientResourceProbe.MODID, value = Dist.CLIENT) public final class ClientResourceProbe { static final String MODID = "advantageclientresources"; private static boolean finished; private static int ticks; - @SubscribeEvent public static void tick(TickEvent.ClientTickEvent event) { - if (!Boolean.getBoolean("advantageworks.clientResources") || finished || event.phase != TickEvent.Phase.END) return; + public ClientResourceProbe() { + 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().getMissingModel() == null) && ticks < 1200)) return; + if (++ticks < 5 || ((minecraft.getOverlay() != null || minecraft.getModelManager().getMissingBlockStateModel() == null) && ticks < 1200)) return; 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.1".equals(result.get("minecraft").getAsString()) - && "52.1.0".equals(result.get("forge").getAsString()), "Wrong native client target"); + 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"); verifyNetworkBootstrap(result); @@ -67,36 +69,40 @@ public final class ClientResourceProbe { 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.14.121011".equals(versions.get("basepower").getAsString()), "Wrong native Power candidate"); + require("2.4.15.121111".equals(versions.get("basepower").getAsString()), "Wrong native Power candidate"); for (String id : new String[]{"basesteam", "baseelectric"}) { - if (versions.has(id)) require("2.2.15.121011".equals(versions.get(id).getAsString()), "Wrong native expansion candidate: " + id); + if (versions.has(id)) require("2.2.16.121111".equals(versions.get(id).getAsString()), "Wrong native expansion candidate: " + id); } - require(minecraft.getModelManager().getMissingModel() != null, "Timed out before model baking"); + require(minecraft.getModelManager().getMissingBlockStateModel() != 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, () -> { int items = 0; int states = 0; - var expectedCreative = new java.util.HashSet(); + List resourceFailures = new ArrayList<>(); + var expectedCreative = new java.util.HashSet(); 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"); - BakedModel missing = minecraft.getModelManager().getMissingModel(); + BlockStateModel missing = minecraft.getModelManager().getMissingBlockStateModel(); for (Item item : ForgeRegistries.ITEMS) { - ResourceLocation id = ForgeRegistries.ITEMS.getKey(item); + Identifier id = ForgeRegistries.ITEMS.getKey(item); if (id == null || !included(id.getNamespace())) continue; ItemStack stack = new ItemStack(item); - String key = item.getDescriptionId(stack); - require(!I18n.get(key).equals(key), "Untranslated item: " + id + " -> " + key); - BakedModel model = minecraft.getItemRenderer().getModel(stack, null, null, 0); - verifyModel(model, missing, null, id.toString(), true); + String key = item.getDescriptionId(); + capture("item-name/" + id, resourceFailures, + () -> require(!I18n.get(key).equals(key), "Untranslated item: " + id + " -> " + key)); + capture("item-model/" + id, resourceFailures, () -> verifyItem(minecraft, stack, id.toString())); if (!hidden.contains(id.toString())) expectedCreative.add(id); items++; } for (Block block : ForgeRegistries.BLOCKS) { - ResourceLocation id = ForgeRegistries.BLOCKS.getKey(block); + Identifier id = ForgeRegistries.BLOCKS.getKey(block); if (id == null || !included(id.getNamespace())) continue; for (BlockState state : block.getStateDefinition().getPossibleStates()) { - BakedModel model = minecraft.getModelManager().getBlockModelShaper().getBlockModel(state); - verifyModel(model, missing, state, state.toString(), !(block instanceof net.minecraft.world.level.block.LiquidBlock)); + BlockStateModel model = minecraft.getModelManager().getBlockModelShaper().getBlockModel(state); + capture("block-model/" + state, resourceFailures, + () -> verifyModel(model, missing, state, state.toString(), !(block instanceof net.minecraft.world.level.block.LiquidBlock))); states++; } } @@ -110,9 +116,9 @@ public final class ClientResourceProbe { require(!tab.getIconItem().isEmpty(), "Power Advantage creative tab has no icon"); require(I18n.exists("itemGroup.basepower") && tab.getDisplayName().getString().equals(I18n.get("itemGroup.basepower")), "The shared creative tab title is not translated"); - var creativeIds = new java.util.HashSet(); + var creativeIds = new java.util.HashSet(); for (ItemStack stack : contents) { - ResourceLocation id = ForgeRegistries.ITEMS.getKey(stack.getItem()); + Identifier id = ForgeRegistries.ITEMS.getKey(stack.getItem()); require(id != null && creativeIds.add(id), "Duplicate or unregistered creative entry: " + id); } var missingCreative = new java.util.HashSet<>(expectedCreative); @@ -123,18 +129,26 @@ public final class ClientResourceProbe { "Wrong creative tab contents. Missing: " + missingCreative + "; unexpected: " + extraCreative); require(I18n.get("block.basepower.still").equals(I18n.get("tile.basepower.still.name")), "Legacy GUI translation differs from modern block name"); - TankVisualProbe.verify(minecraft, result); - MachineGuiLayoutProbe.verify(minecraft, result); - OilRenderProbe.verify(minecraft, result); - RecipeBookGroupingProbe.verify(minecraft, result); - result.addProperty("primary_resource_status", "PASS"); - if (Boolean.getBoolean("advantageworks.childRendererGeometry")) ChildRendererGeometryProbe.verify(minecraft, result); - result.addProperty("status", "PASS"); result.addProperty("items", items); result.addProperty("block_states", states); result.addProperty("creative_entries", contents.size()); - result.addProperty("screen", minecraft.screen == null ? "none" : minecraft.screen.getClass().getName()); + com.google.gson.JsonArray errors = new com.google.gson.JsonArray(); + resourceFailures.forEach(errors::add); + result.add("resource_errors", errors); + require(resourceFailures.isEmpty(), "Registered resource failures: " + resourceFailures); LogManager.getLogger(MODID).info("ADVANTAGE_CLIENT_RESOURCES PASS items={} block_states={} creative_entries={}", items, states, contents.size()); + }); + primary &= inspect("tank_pixels", result, () -> TankVisualProbe.verify(minecraft, result)); + primary &= inspect("machine_gui", result, () -> MachineGuiLayoutProbe.verify(minecraft, result)); + primary &= inspect("oil_renderer", result, () -> OilRenderProbe.verify(minecraft, result)); + primary &= inspect("recipe_book", result, () -> RecipeBookGroupingProbe.verify(minecraft, result)); + 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"); + result.addProperty("status", "PASS"); + result.addProperty("screen", minecraft.screen == null ? "none" : minecraft.screen.getClass().getName()); } catch (RuntimeException | LinkageError | AssertionError failure) { result.addProperty("status", "FAIL"); result.addProperty("error", failure.toString()); @@ -146,6 +160,30 @@ public final class ClientResourceProbe { } } + 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(); + report.getAsJsonObject("scope_results").add(name, result); + try { + check.run(); + result.addProperty("status", "PASS"); + return true; + } catch (RuntimeException | LinkageError | AssertionError failure) { + result.addProperty("status", "FAIL"); + result.addProperty("error", failure.toString()); + LogManager.getLogger(MODID).error("ADVANTAGE_CLIENT_SCOPE FAIL " + name, failure); + return false; + } + } + + private static void capture(String name, List failures, Runnable check) { + try { check.run(); } + catch (RuntimeException | LinkageError | AssertionError failure) { + failures.add(name + ": " + failure); + LogManager.getLogger(MODID).error("ADVANTAGE_CLIENT_RESOURCE FAIL " + name, failure); + } + } + private static boolean included(String namespace) { return "basepower".equals(namespace) || (Boolean.getBoolean("advantageworks.requireExtensions") && ("basesteam".equals(namespace) || "baseelectric".equals(namespace))); @@ -163,15 +201,65 @@ private static void verifyNetworkBootstrap(JsonObject result) { } } - private static void verifyModel(BakedModel model, BakedModel missing, BlockState state, String id, boolean requireQuads) { + 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); + require(!state.isEmpty(), "Empty native item model: " + id); + require(!"missingno".equals(state.pickParticleIcon(RandomSource.create(42L)).contents().name().getPath()), "Missing particle texture: " + id); + try { + var countField = ItemStackRenderState.class.getDeclaredField("activeLayerCount"); + var layersField = ItemStackRenderState.class.getDeclaredField("layers"); + countField.setAccessible(true); + layersField.setAccessible(true); + var layers = (ItemStackRenderState.LayerRenderState[]) layersField.get(state); + 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); + quads++; + } + } + require(quads > 0, "Empty baked model: " + id); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Cannot inspect native item layers: " + id, failure); + } + } + + 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.getParticleIcon(net.minecraftforge.client.model.data.ModelData.EMPTY) + require(!"missingno".equals(model.particleIcon(net.minecraftforge.client.model.data.ModelData.EMPTY) .contents().name().getPath()), "Missing particle texture: " + id); RandomSource random = RandomSource.create(42L); - List quads = new ArrayList<>(model.getQuads(state, null, random)); - for (Direction side : Direction.values()) quads.addAll(model.getQuads(state, side, random)); + 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); + 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.getSprite().contents().name().getPath()), "Missing face texture: " + id); + for (BakedQuad quad : quads) require(!"missingno".equals(quad.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); + JsonObject control = new JsonObject(); + report.add("vanilla_stone_particle_control", control); + control.addProperty("model_class", model.getClass().getName()); + control.addProperty("deprecated_no_context", legacy.contents().name().toString()); + control.addProperty("native_model_data", nativeIcon.contents().name().toString()); + control.addProperty("native_shaper", shaperIcon.contents().name().toString()); + 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); + control.addProperty("status", "PASS"); } private static void require(boolean condition, String message) { 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 90553d94..9e958f09 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 @@ -3,7 +3,6 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import com.mojang.blaze3d.systems.RenderSystem; import cyano.poweradvantage.api.GUIBlock; import cyano.poweradvantage.api.PoweredEntity; import cyano.poweradvantage.api.fluid.FluidStack; @@ -16,7 +15,6 @@ import java.nio.charset.StandardCharsets; import java.lang.reflect.Field; import java.lang.reflect.Method; -import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -28,7 +26,6 @@ import net.minecraft.client.gui.Font; import net.minecraft.world.inventory.tooltip.TooltipComponent; import net.minecraft.client.player.LocalPlayer; -import com.mojang.blaze3d.pipeline.RenderTarget; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Inventory; @@ -38,7 +35,6 @@ import net.minecraft.core.BlockPos; import net.minecraftforge.registries.ForgeRegistries; import zone.moddev.mc.basepower.registry.MachineGUIRegistry; -import org.joml.Vector3f; /** Renders every registered machine screen, not just its detached decorations. */ final class MachineGuiLayoutProbe { @@ -65,7 +61,7 @@ static void verify(Minecraft minecraft, JsonObject result) { singleton.setAccessible(true); LocalPlayer player = (LocalPlayer) allocator.getMethod("allocateInstance", Class.class) .invoke(singleton.get(null), LocalPlayer.class); - Inventory inventory = new Inventory(player); + Inventory inventory = new Inventory(player, new net.minecraft.world.entity.EntityEquipment()); Field inventoryField = Player.class.getDeclaredField("inventory"); inventoryField.setAccessible(true); inventoryField.set(player, inventory); @@ -91,7 +87,7 @@ static void verify(Minecraft minecraft, JsonObject result) { require(definition instanceof SimpleMachineGUI, "No shared screen definition"); Field textureField = SimpleMachineGUI.class.getDeclaredField("guiDisplayImage"); textureField.setAccessible(true); - net.minecraft.resources.ResourceLocation texture = (net.minecraft.resources.ResourceLocation) textureField.get(definition); + net.minecraft.resources.Identifier texture = (net.minecraft.resources.Identifier) textureField.get(definition); try (var resource = minecraft.getResourceManager().open(texture)) { BufferedImage artwork = ImageIO.read(resource); // The GUI UVs use a logical 256-square atlas; Electric supplies 2x artwork. @@ -127,7 +123,7 @@ static void verify(Minecraft minecraft, JsonObject result) { row.addProperty("slots", menu.slots.size()); int renders = 0; for (int[] viewport : VIEWPORTS) { - screen.init(minecraft, viewport[0], viewport[1]); + screen.init(viewport[0], viewport[1]); verifySlots(screen, menu); for (int fill = 0; fill <= 2; fill++) { for (net.minecraft.world.level.block.state.properties.Property property : block.defaultBlockState().getProperties()) { @@ -162,7 +158,7 @@ static void verify(Minecraft minecraft, JsonObject result) { } } inventory.setItem(0, new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND, 2)); - screen.init(minecraft, 320, 240); + screen.init(320, 240); BufferedImage tooltip = render(minecraft, screen, VIEWPORTS[0], false, true); require(screen.itemTooltipCalls == 1, "Hovered inventory item has no native tooltip"); require(screen.hovered() == menu.slots.get(menu.slots.size() - 9), "Inventory hitbox is misaligned"); @@ -248,44 +244,19 @@ 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]; - RenderTarget target = new com.mojang.blaze3d.pipeline.TextureTarget(width, height, true, Minecraft.ON_OSX); - target.bindWrite(true); - NativeGuiProjection projection = new NativeGuiProjection(viewport[0], viewport[1], 1000, 3000, -2000); - RenderSystem.enableDepthTest(); - RenderSystem.depthFunc(515); - RenderSystem.disableBlend(); - RenderSystem.setShaderColor(1, 1, 1, 1); - try { - RenderSystem.clearColor(0.12F, 0.18F, 0.23F, 1); - RenderSystem.clear(16384 | 256, Minecraft.ON_OSX); - GuiGraphics graphics = screen.recordingGraphics(minecraft); + return NativeGuiProjection.render(minecraft, width, height, viewport[2], 0xFF1E2D3A, + state -> screen.recordingGraphics(minecraft, state), graphics -> { if (backgroundOnly) { LabelMode original = screen.labels; screen.labels = LabelMode.HIDDEN; - screen.render(graphics, -100, -100, 0F); - screen.labels = original; + try { screen.render(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.hoverTitle ? screen.getTop() + 4 : hover ? screen.getTop() + hotbar.y + 1 : -100, 0F); } - graphics.flush(); - ByteBuffer pixels = ByteBuffer.allocateDirect(width * height * 4); - RenderSystem.readPixels(0, 0, width, height, 6408, 5121, pixels); - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { - int index = ((height - y - 1) * width + x) * 4; - image.setRGB(x, y, (pixels.get(index) & 255) << 16 - | (pixels.get(index + 1) & 255) << 8 | pixels.get(index + 2) & 255); - } - return image; - } finally { - projection.close(); - target.destroyBuffers(); - minecraft.getMainRenderTarget().bindWrite(true); - RenderSystem.enableDepthTest(); - RenderSystem.setShaderColor(1, 1, 1, 1); - } + }); } private static String labelError(BufferedImage actual, BufferedImage expected, BufferedImage background, @@ -343,7 +314,7 @@ private static void verifyLongName(Minecraft minecraft, SimpleMachineGUI definit String name = String.join(" / ", java.util.Collections.nCopies(16, "John's oil reserve")); RecordingScreen screen = new RecordingScreen(definition, menu, inventory, net.minecraft.network.chat.Component.literal(name), name, "Inventory"); - screen.init(minecraft, 320, 240); + screen.init(320, 240); BufferedImage actual = render(minecraft, screen, VIEWPORTS[0], false); BufferedImage hidden = render(minecraft, screen, VIEWPORTS[0], true); screen.labels = LabelMode.EXPECTED; @@ -391,8 +362,7 @@ 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 GuiWorld() { - super(null, null, net.minecraft.world.level.Level.OVERWORLD, null, 0, 0, - () -> net.minecraft.util.profiling.InactiveProfiler.INSTANCE, null, false, 0); + 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; } @@ -428,9 +398,9 @@ 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, 0xFFFFFF); + graphics.drawString(font, text, textX, labels == LabelMode.OVER_GAUGE ? 20 : 1, 0xFFFFFFFF); } - if (labels != LabelMode.TITLE_ONLY) graphics.drawString(font, expectedInventory, 8, 140, 0xFFFFFF); + if (labels != LabelMode.TITLE_ONLY) graphics.drawString(font, expectedInventory, 8, 140, 0xFFFFFFFF); } @Override public void render(GuiGraphics graphics, int x, int y, float ticks) { draws.clear(); tooltipCalls = 0; itemTooltipCalls = 0; titleTooltip = null; @@ -440,29 +410,29 @@ private record DrawBounds(float left, float top, float right, float bottom, int tooltipCalls++; super.renderTooltip(graphics, x, y); } - GuiGraphics recordingGraphics(Minecraft minecraft) { - return new GuiGraphics(minecraft, minecraft.renderBuffers().bufferSource()) { - @Override public void renderTooltip(Font font, List lines, + GuiGraphics recordingGraphics(Minecraft minecraft, net.minecraft.client.gui.render.state.GuiRenderState state) { + return new GuiGraphics(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) { itemTooltipCalls++; - super.renderTooltip(font, lines, component, item, x, y); + super.setTooltipForNextFrame(font, lines, component, item, x, y); } - @Override public void renderTooltip(Font font, net.minecraft.network.chat.Component text, int x, int y) { + @Override public void setTooltipForNextFrame(Font font, net.minecraft.network.chat.Component text, int x, int y) { titleTooltip = text.getString(); - super.renderTooltip(font, text, x, y); + super.setTooltipForNextFrame(font, text, x, y); } }; } Slot hovered() { return hoveredSlot; } - @Override public void blit(net.minecraft.resources.ResourceLocation texture, + @Override public void blit(net.minecraft.resources.Identifier texture, int x, int y, int u, int v, int width, int height) { - var pose = getMatrices().last().pose(); + var pose = getMatrices(); float left = Float.POSITIVE_INFINITY, top = Float.POSITIVE_INFINITY; float right = Float.NEGATIVE_INFINITY, bottom = Float.NEGATIVE_INFINITY; for (int px : new int[]{x, x + width}) for (int py : new int[]{y, y + height}) { - Vector3f corner = pose.transformPosition(px, py, 0, new Vector3f()); + org.joml.Vector2f corner = pose.transformPosition(px, py, new org.joml.Vector2f()); float relativeX = corner.x - getLeft(), relativeY = corner.y - getTop(); left = Math.min(left, relativeX); top = Math.min(top, relativeY); diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/NativeGuiProjection.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/NativeGuiProjection.java index dcffbc59..c61ea95b 100644 --- a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/NativeGuiProjection.java +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/NativeGuiProjection.java @@ -1,28 +1,111 @@ package zone.moddev.mc.advantageworks.client; import com.mojang.blaze3d.systems.RenderSystem; -import com.mojang.blaze3d.vertex.VertexSorting; -import org.joml.Matrix4f; -import org.joml.Matrix4fStack; +import com.mojang.blaze3d.ProjectionType; +import com.mojang.blaze3d.buffers.GpuBufferSlice; +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.pipeline.TextureTarget; +import com.mojang.blaze3d.platform.NativeImage; +import java.awt.image.BufferedImage; +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.render.GuiRenderer; +import net.minecraft.client.gui.render.state.GuiRenderState; +import net.minecraft.client.renderer.fog.FogRenderer; -/** Scoped native shader projection for framebuffer-based screen tests. */ +/** Scoped native GUI extraction, GPU submission and fenced framebuffer readback. */ final class NativeGuiProjection implements AutoCloseable { - private final Matrix4f projection = new Matrix4f(RenderSystem.getProjectionMatrix()); - private final VertexSorting sorting = RenderSystem.getVertexSorting(); - private final Matrix4fStack modelView = RenderSystem.getModelViewStack(); + private final Minecraft minecraft; + private final RenderTarget original; + private final RenderTarget target; + private final Field targetField; + private final Field widthField, heightField; + private final int width, height, scale; + private final GpuBufferSlice projection = RenderSystem.getProjectionMatrixBuffer(); + private final ProjectionType projectionType = RenderSystem.getProjectionType(); - NativeGuiProjection(float width, float height, float near, float far, float depth) { - RenderSystem.setProjectionMatrix(new Matrix4f().setOrtho(0, width, height, 0, near, far), - VertexSorting.ORTHOGRAPHIC_Z); - modelView.pushMatrix(); - modelView.identity(); - modelView.translate(0, 0, depth); - RenderSystem.applyModelViewMatrix(); + private NativeGuiProjection(Minecraft minecraft, int width, int height, int scale) throws ReflectiveOperationException { + this.minecraft = minecraft; + original = minecraft.getMainRenderTarget(); + var window = minecraft.getWindow(); + this.width = window.getWidth(); + this.height = window.getHeight(); + this.scale = window.getGuiScale(); + targetField = Minecraft.class.getDeclaredField("mainRenderTarget"); + targetField.setAccessible(true); + widthField = window.getClass().getDeclaredField("framebufferWidth"); + heightField = window.getClass().getDeclaredField("framebufferHeight"); + widthField.setAccessible(true); + heightField.setAccessible(true); + target = new TextureTarget("Advantage native GUI witness", width, height, true); + targetField.set(minecraft, target); + widthField.setInt(window, width); + heightField.setInt(window, height); + window.setGuiScale(scale); } - @Override public void close() { - modelView.popMatrix(); - RenderSystem.applyModelViewMatrix(); - RenderSystem.setProjectionMatrix(projection, sorting); + static BufferedImage render(Minecraft minecraft, int width, int height, int scale, int background, + Consumer draw) { + return render(minecraft, width, height, scale, background, + state -> new GuiGraphics(minecraft, state, -100, -100), draw); + } + + static BufferedImage render(Minecraft minecraft, int width, int height, int scale, int background, + Function graphicsFactory, Consumer draw) { + GuiRenderState state = new GuiRenderState(); + try (NativeGuiProjection scope = new NativeGuiProjection(minecraft, width, height, scale); + GuiRenderer renderer = new GuiRenderer(state, minecraft.renderBuffers().bufferSource(), + minecraft.gameRenderer.getSubmitNodeStorage(), + minecraft.gameRenderer.getFeatureRenderDispatcher(), List.of())) { + RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures( + scope.target.getColorTexture(), background, scope.target.getDepthTexture(), 1.0); + GuiGraphics graphics = graphicsFactory.apply(state); + draw.accept(graphics); + graphics.renderDeferredElements(); + Field fogField = minecraft.gameRenderer.getClass().getDeclaredField("fogRenderer"); + fogField.setAccessible(true); + FogRenderer fog = (FogRenderer) fogField.get(minecraft.gameRenderer); + renderer.render(fog.getBuffer(FogRenderer.FogMode.NONE)); + return read(scope.target); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Cannot scope the actual native GUI target", failure); + } + } + + private static BufferedImage read(RenderTarget target) { + AtomicReference captured = new AtomicReference<>(); + Screenshot.takeScreenshot(target, captured::set); + try (var fence = RenderSystem.getDevice().createCommandEncoder().createFence()) { + if (!fence.awaitCompletion(10_000_000_000L)) throw new IllegalStateException("Native GUI readback fence timed out"); + RenderSystem.executePendingTasks(); + NativeImage image = captured.get(); + if (image == null) throw new IllegalStateException("Native screenshot callback did not complete"); + try (image) { + BufferedImage result = new BufferedImage(target.width, target.height, BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < target.height; y++) for (int x = 0; x < target.width; x++) + result.setRGB(x, y, image.getPixel(x, y)); + return result; + } + } + } + + @Override public void close() throws ReflectiveOperationException { + try { + targetField.set(minecraft, original); + widthField.setInt(minecraft.getWindow(), width); + heightField.setInt(minecraft.getWindow(), height); + minecraft.getWindow().setGuiScale(scale); + RenderSystem.setProjectionMatrix(projection, projectionType); + } finally { + minecraft.gameRenderer.getSubmitNodeStorage().endFrame(); + minecraft.gameRenderer.getFeatureRenderDispatcher().endFrame(); + target.destroyBuffers(); + } } } diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/OilRenderProbe.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/OilRenderProbe.java index 03cc4dc0..da44dfe4 100644 --- a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/OilRenderProbe.java +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/OilRenderProbe.java @@ -6,7 +6,6 @@ import net.minecraft.client.Minecraft; import com.mojang.blaze3d.vertex.BufferBuilder; import net.minecraft.client.renderer.texture.TextureAtlasSprite; -import net.minecraft.client.renderer.texture.TextureAtlas; import com.mojang.blaze3d.vertex.DefaultVertexFormat; import com.mojang.blaze3d.vertex.VertexFormatElement; import net.minecraft.world.level.material.Fluid; @@ -14,7 +13,7 @@ import net.minecraft.world.level.material.FluidState; import net.minecraft.world.item.BucketItem; import net.minecraft.world.item.ItemStack; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Direction; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; @@ -34,7 +33,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; /** Exercises the native fluid renderer, not just oil's particle-only block model. */ final class OilRenderProbe { @@ -48,7 +46,8 @@ static void verify(Minecraft minecraft, JsonObject result) { net.minecraft.world.level.material.Fluids.FLOWING_WATER.builtInRegistryHolder())); synthetic.put(FluidTags.LAVA, List.of(net.minecraft.world.level.material.Fluids.LAVA.builtInRegistryHolder(), net.minecraft.world.level.material.Fluids.FLOWING_LAVA.builtInRegistryHolder())); - BuiltInRegistries.FLUID.bindTags(synthetic); + BuiltInRegistries.FLUID.prepareTagReload(new net.minecraft.tags.TagLoader.LoadResult<>( + net.minecraft.core.registries.Registries.FLUID, synthetic)).apply(); try { requireBucket(Items.crude_oil_bucket, Fluids.crude_oil); requireBucket(Items.refined_oil_bucket, Fluids.refined_oil); @@ -65,8 +64,8 @@ static void verify(Minecraft minecraft, JsonObject result) { valid &= check(minecraft, net.minecraft.world.level.material.Fluids.LAVA.defaultFluidState(), "minecraft:block/lava_still", false, cases); if (!valid) throw new IllegalStateException("Native fluid renderer used the wrong sprite or tint: " + cases); } finally { - BuiltInRegistries.FLUID.resetTags(); - BuiltInRegistries.FLUID.bindTags(previous); + BuiltInRegistries.FLUID.prepareTagReload(new net.minecraft.tags.TagLoader.LoadResult<>( + net.minecraft.core.registries.Registries.FLUID, previous)).apply(); require(fluidTags().equals(previous), "Synthetic fluid tag membership was not restored"); } } @@ -74,8 +73,8 @@ static void verify(Minecraft minecraft, JsonObject result) { private static Map, List>> fluidTags() { Map, List>> tags = new HashMap<>(); BuiltInRegistries.FLUID.getTags().forEach(tag -> { - List> values = tag.getSecond().stream().toList(); - if (!values.isEmpty()) tags.put(tag.getFirst(), values); + List> values = tag.stream().toList(); + tags.put(tag.key(), values); }); return tags; } @@ -94,7 +93,7 @@ private static boolean check(Minecraft minecraft, FluidState state, String textu // A tiny read-only neighbourhood supplies a slope for flowing sprites; no save is opened. BlockAndTintGetter world = new BlockAndTintGetter() { @Override public int getHeight() { return 256; } - @Override public int getMinBuildHeight() { return 0; } + @Override public int getMinY() { return 0; } @Override public float getShade(Direction side, boolean shade) { return 1.0F; } @Override public BlockEntity getBlockEntity(BlockPos pos) { return null; } @Override public BlockState getBlockState(BlockPos pos) { return blockAt(state, flowing, pos); } @@ -111,7 +110,8 @@ private static boolean check(Minecraft minecraft, FluidState state, String textu BlockPos.ZERO, world, buffer, world.getBlockState(BlockPos.ZERO), state); try (var built = buffer.buildOrThrow()) { int vertices = built.drawState().vertexCount(); - TextureAtlasSprite sprite = minecraft.getTextureAtlas(TextureAtlas.LOCATION_BLOCKS).apply(ResourceLocation.parse(texture)); + TextureAtlasSprite sprite = minecraft.getAtlasManager().getAtlasOrThrow(net.minecraft.data.AtlasIds.BLOCKS) + .getSprite(Identifier.parse(texture)); require(vertices >= 4, "Native fluid renderer emitted no top face for " + state); ByteBuffer data = built.vertexBuffer(); int uvOffset = elementOffset(VertexFormatElement.Usage.UV, 0); diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/RecipeBookGroupingProbe.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/RecipeBookGroupingProbe.java index 353ada2b..e0b79625 100644 --- a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/RecipeBookGroupingProbe.java +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/RecipeBookGroupingProbe.java @@ -7,9 +7,10 @@ import java.awt.image.BufferedImage; import java.io.File; import java.lang.reflect.Field; -import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -20,35 +21,34 @@ import java.util.stream.Stream; import javax.imageio.ImageIO; import net.minecraft.client.Minecraft; +import net.minecraft.client.ClientRecipeBook; +import net.minecraft.client.gui.screens.recipebook.RecipeBookPage; import net.minecraft.client.gui.screens.recipebook.RecipeButton; import net.minecraft.client.gui.screens.recipebook.RecipeCollection; -import com.mojang.blaze3d.systems.RenderSystem; -import com.mojang.blaze3d.pipeline.RenderTarget; -import net.minecraft.client.ClientRecipeBook; import net.minecraft.core.RegistryAccess; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; import net.minecraft.nbt.NbtAccounter; import net.minecraft.nbt.NbtIo; -import net.minecraft.nbt.Tag; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.crafting.Recipe; -import net.minecraft.world.item.crafting.RecipeHolder; -import net.minecraft.stats.RecipeBook; -import net.minecraft.world.entity.player.StackedContents; -import net.minecraft.world.item.crafting.RecipeManager; import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.protocol.game.ClientboundUpdateRecipesPacket; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.common.ClientboundUpdateTagsPacket; +import net.minecraft.network.protocol.game.ClientboundRecipeBookAddPacket; +import net.minecraft.resources.Identifier; +import net.minecraft.util.context.ContextMap; +import net.minecraft.world.entity.player.StackedItemContents; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; +import net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplayContext; import net.minecraftforge.registries.ForgeRegistries; import net.minecraftforge.registries.ForgeRegistry; import net.minecraftforge.registries.GameData; import net.minecraftforge.registries.RegistryManager; -/** Decodes actual server recipe packets and exercises Minecraft's real cell grouping. */ +/** Decodes server display packets and exercises the actual native client collection builder. */ final class RecipeBookGroupingProbe { - private static final int GL_COLOR_BUFFER_BIT = 16384, GL_DEPTH_BUFFER_BIT = 256, GL_RGBA = 6408, GL_UNSIGNED_BYTE = 5121; static void verify(Minecraft minecraft, JsonObject report) { Path root = Path.of("recipe-book-fixtures"); if (!Files.isDirectory(root)) { @@ -58,156 +58,224 @@ static void verify(Minecraft minecraft, JsonObject report) { JsonArray checks = new JsonArray(); report.add("recipe_book_grouping", checks); try (Stream files = Files.list(root)) { - // A separate main-menu client has not performed Forge's registry handshake. - // Import only the generated server fixture's ID snapshot, never a user world. - Map registry = RegistryManager.ACTIVE.takeSnapshot(false); + Map registry = RegistryManager.ACTIVE.takeSnapshot(false); CompoundTag level = NbtIo.readCompressed(root.resolve("server-registries.dat"), NbtAccounter.unlimitedHeap()); - require("1.21.1".equals(level.getCompound("Data").getCompound("Version").getString("Name")), - "Recipe fixture is not a native 1.21.1 server export"); - ListTag mods = level.getCompound("fml").getList("LoadingModList", Tag.TAG_COMPOUND); + require("1.21.11".equals(compound(compound(level, "Data"), "Version").getString("Name").orElseThrow()), + "Recipe fixture is not a native 1.21.11 server export"); boolean nativeForge = false; - for (int i = 0; i < mods.size(); i++) { - CompoundTag mod = mods.getCompound(i); - if ("forge".equals(mod.getString("ModId"))) nativeForge = "52.1.0".equals(mod.getString("ModVersion")); + for (var entry : compound(level, "fml").getList("LoadingModList").orElseThrow()) { + require(entry instanceof CompoundTag, "Malformed native loading-mod entry"); + CompoundTag mod = (CompoundTag) entry; + if ("forge".equals(mod.getString("ModId").orElseThrow())) + nativeForge = "61.1.0".equals(mod.getString("ModVersion").orElseThrow()); } - require(nativeForge, "Recipe fixture was not saved by Forge 52.1.0"); - CompoundTag saved = level.getCompound("fml").getCompound("Registries"); - String itemRegistry = ForgeRegistries.ITEMS.getRegistryName().toString(); - String blockRegistry = ForgeRegistries.BLOCKS.getRegistryName().toString(); - require(saved.contains(itemRegistry, Tag.TAG_COMPOUND) && saved.contains(blockRegistry, Tag.TAG_COMPOUND) - && !saved.getCompound(itemRegistry).getList("ids", Tag.TAG_COMPOUND).isEmpty() - && !saved.getCompound(blockRegistry).getList("ids", Tag.TAG_COMPOUND).isEmpty(), "Missing native server registry snapshot"); - for (String key : saved.getAllKeys()) registry.put(ResourceLocation.parse(key), - ForgeRegistry.Snapshot.read(saved.getCompound(key))); + require(nativeForge, "Recipe fixture was not saved by Forge 61.1.0"); + CompoundTag saved = compound(compound(level, "fml"), "Registries"); + for (String key : List.of(ForgeRegistries.ITEMS.getRegistryName().toString(), + ForgeRegistries.BLOCKS.getRegistryName().toString())) { + require(!compound(saved, key).getList("ids").orElseThrow().isEmpty(), "Missing native server registry snapshot: " + key); + } + for (String key : saved.keySet()) registry.put(Identifier.parse(key), ForgeRegistry.Snapshot.read(compound(saved, key))); require(GameData.injectSnapshot(registry, false, false).isEmpty(), "Client lacks a server registry entry"); RegistryAccess registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); - List packets = files.filter(file -> file.toString().endsWith(".bin")).sorted().toList(); + List packets = files.filter(file -> file.getFileName().toString() + .matches("recipes-(normal|tech|apocalyptic)-sweep\\.bin")).sorted().toList(); Set packetNames = packets.stream().map(file -> file.getFileName().toString()).collect(Collectors.toSet()); require(packetNames.equals(Set.of("recipes-normal-sweep.bin", "recipes-tech-sweep.bin", "recipes-apocalyptic-sweep.bin")), "Normal, Tech and Apocalyptic recipe-book packets are required"); for (Path file : packets) { Path manifestPath = file.resolveSibling(file.getFileName().toString().replace(".bin", ".json")); JsonObject manifest = JsonParser.parseString(Files.readString(manifestPath)).getAsJsonObject(); - require("PASS".equals(manifest.get("status").getAsString()), "Server recipe fixture did not pass"); - Map expected = new HashMap<>(); - manifest.getAsJsonArray("bookRecipes").forEach(row -> require(expected.put(row.getAsJsonObject().get("id").getAsString(), - row.getAsJsonObject()) == null, "Duplicate recipe in packet manifest")); - RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf( - Unpooled.wrappedBuffer(Files.readAllBytes(file)), registries); - ClientboundUpdateRecipesPacket packet; - try { - packet = ClientboundUpdateRecipesPacket.STREAM_CODEC.decode(buffer); - require(!buffer.isReadable(), "Trailing recipe packet bytes"); - } - finally { buffer.release(); } - RecipeManager manager = new RecipeManager(registries); - Set cellKeys = new HashSet<>(); - for (RecipeHolder holder : packet.getRecipes()) { - Recipe recipe = holder.value(); - JsonObject row = expected.get(holder.id().toString()); - require(row != null, "Unexpected network recipe " + holder.id()); - ItemStack stack = recipe.getResultItem(registries); - String output = ForgeRegistries.ITEMS.getKey(stack.getItem()).toString(); - require(output.equals(row.get("output").getAsString()) && stack.getCount() == row.get("count").getAsInt(), - "Client/server item registry mismatch for " + holder.id()); - require(recipe.getGroup().equals(row.get("group").getAsString()) && recipe.getGroup().equals(output), "Group changed in recipe packet"); - String type = BuiltInRegistries.RECIPE_TYPE.getKey(recipe.getType()).toString(); - require(type.equals(row.get("type").getAsString()), "Recipe type changed in packet"); - cellKeys.add(type + "|" + output); - } - manager.replaceRecipes(packet.getRecipes()); - require(manager.getRecipes().size() == expected.size(), "Missing network recipes"); - ClientRecipeBook book = new ClientRecipeBook(); - manager.getRecipes().forEach(book::add); - book.setupCollections(manager.getRecipes(), registries); - int counted = 0; - for (RecipeCollection cell : book.getCollections()) { - Set outputs = cell.getRecipes().stream().map(holder -> ForgeRegistries.ITEMS.getKey( - holder.value().getResultItem(registries).getItem()).toString()) - .collect(Collectors.toSet()); - require(outputs.size() == 1 && cell.hasSingleResultItem(), "Mixed items share a native recipe-book cell: " + outputs); - counted += cell.getRecipes().size(); - cell.updateKnownRecipes(book); - cell.canCraft(new StackedContents(), 3, 3, book); - require(cell.getRecipes(false).size() == cell.getRecipes().size(), "Unlocked variants hidden in native cell"); - } - require(counted == expected.size() && book.getCollections().size() == cellKeys.size(), "Missing or split native cells"); + require(manifest.has("tagsPacketType") && "ClientboundUpdateTagsPacket".equals(manifest.get("tagsPacketType").getAsString()), + "Recipe fixture omitted its native item-tag packet"); + String tagsName = manifest.get("tagsPacketFile").getAsString(); + require(Path.of(tagsName).getFileName().toString().equals(tagsName) && tagsName.startsWith("tags-") && tagsName.endsWith(".bin"), + "Tag packet must be a sibling native export"); + byte[] tags = Files.readAllBytes(file.resolveSibling(tagsName)); + require(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(tags)) + .equalsIgnoreCase(manifest.get("tagsPacketSha256").getAsString()), "Native tag packet SHA-256 mismatch"); + int tagCount = applyItemTags(registries, tags); + require(tagCount == manifest.get("itemTagCount").getAsInt(), "Native item-tag count mismatch"); + ContextMap context = context(registries); + VerifiedBook verified = verifyPacket(registries, context, Files.readAllBytes(file), manifest); JsonObject check = new JsonObject(); check.addProperty("packet", file.getFileName().toString()); - check.addProperty("recipes", counted); - check.addProperty("cells", book.getCollections().size()); + check.addProperty("packet_type", "ClientboundRecipeBookAddPacket"); + check.addProperty("recipes", verified.recipes()); + check.addProperty("displays", verified.displays()); + check.addProperty("cells", verified.book().getCollections().size()); + check.addProperty("item_tags", tagCount); check.addProperty("status", "PASS"); checks.add(check); - if (file.getFileName().toString().equals("recipes-normal-sweep.bin")) render(minecraft, book); + if (file.getFileName().toString().equals("recipes-normal-sweep.bin")) render(minecraft, verified.book(), context); } - } catch (Exception failure) { throw new IllegalStateException("Native recipe-book validation failed", failure); } + } catch (Exception failure) { throw new IllegalStateException("Native recipe-book validation failed: " + failure, failure); } report.addProperty("recipe_book_grouping_status", "PASS"); } - private static void render(Minecraft minecraft, ClientRecipeBook book) throws Exception { + record VerifiedBook(ClientRecipeBook book, int recipes, int displays) {} + + static int applyItemTags(RegistryAccess registries, byte[] bytes) { + FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.wrappedBuffer(bytes)); + ClientboundUpdateTagsPacket packet; + try { + packet = ClientboundUpdateTagsPacket.STREAM_CODEC.decode(buffer); + require(!buffer.isReadable(), "Trailing native tag packet bytes"); + } finally { buffer.release(); } + var payload = packet.getTags().get(net.minecraft.core.registries.Registries.ITEM); + require(payload != null && !payload.isEmpty(), "Native packet has no item tags"); + var items = registries.lookupOrThrow(net.minecraft.core.registries.Registries.ITEM); + var resolved = payload.resolve(items); + require(resolved.tags().size() == payload.size(), "Native item-tag resolution lost tags"); + FriendlyByteBuf members = new FriendlyByteBuf(Unpooled.buffer()); + try { + payload.write(members); + Map expected = members.readMap( + FriendlyByteBuf::readIdentifier, FriendlyByteBuf::readIntIdList); + require(!members.isReadable(), "Native item-tag member payload has trailing bytes"); + resolved.tags().forEach((key, holders) -> require(holders.stream().map(holder -> items.getId(holder.value())).toList() + .equals(expected.get(key.location())), "Native item-tag resolution dropped/changed a member: " + key.location())); + } finally { members.release(); } + // The same payload.resolve/prepareTagReload/apply path as ClientPacketListener.handleUpdateTags. + items.prepareTagReload(resolved).apply(); + require(items.getTags().count() == payload.size(), "Native registry lost applied item tags"); + return payload.size(); + } + + static VerifiedBook verifyPacket(RegistryAccess registries, ContextMap context, byte[] bytes, JsonObject manifest) { + require("PASS".equals(manifest.get("status").getAsString()), "Server recipe fixture did not pass"); + require(manifest.has("bookPacketType") && "ClientboundRecipeBookAddPacket".equals(manifest.get("bookPacketType").getAsString()), + "Recipe fixture must identify the native display-packet protocol"); + Map recipes = new HashMap<>(); + Map displays = new HashMap<>(); + Map> expectedCells = new HashMap<>(); + for (var value : manifest.getAsJsonArray("bookRecipes")) { + JsonObject row = value.getAsJsonObject(); + require(recipes.put(row.get("id").getAsString(), row) == null, "Duplicate recipe in packet manifest"); + require(row.get("group").getAsString().equals(row.get("output").getAsString()), "Manifest group is not output-specific"); + require(row.has("displayIds") && !row.getAsJsonArray("displayIds").isEmpty(), "Recipe manifest omitted native display IDs"); + for (var display : row.getAsJsonArray("displayIds")) { + int id = display.getAsInt(); + require(id >= 0 && displays.put(id, row) == null, "Duplicate/invalid display ID in manifest: " + id); + expectedCells.computeIfAbsent(cellKey(row), ignored -> new HashSet<>()).add(id); + } + } + require(!recipes.isEmpty(), "Empty native recipe-book fixture"); + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.wrappedBuffer(bytes), registries); + ClientboundRecipeBookAddPacket packet; + try { + packet = ClientboundRecipeBookAddPacket.STREAM_CODEC.decode(buffer); + require(!buffer.isReadable(), "Trailing recipe packet bytes"); + require(packet.replace(), "Expected a complete native recipe-book snapshot"); + } finally { buffer.release(); } + ClientRecipeBook book = new ClientRecipeBook(); + Set seen = new HashSet<>(); + Map groupOutputs = new HashMap<>(); + for (var entry : packet.entries()) { + RecipeDisplayEntry display = entry.contents(); + int id = display.id().index(); + JsonObject row = displays.get(id); + require(row != null && seen.add(id), "Unexpected/duplicate network display " + id); + ItemStack stack = result(display, context); + String output = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(); + require(output.equals(row.get("output").getAsString()) && stack.getCount() == row.get("count").getAsInt(), + "Client/server item registry or count mismatch for " + row.get("id")); + require(display.group().isPresent(), "Native recipe display lost its group"); + String previous = groupOutputs.putIfAbsent(display.group().getAsInt(), output); + require(previous == null || previous.equals(output), "Native numeric group mixes output items"); + book.add(display); + if (entry.highlight()) book.addHighlight(display.id()); + } + require(seen.equals(displays.keySet()), "Missing network displays"); + book.rebuildCollections(); + Set collected = new HashSet<>(); + Set actualCells = new HashSet<>(); + for (RecipeCollection cell : book.getCollections()) { + Set keys = new HashSet<>(); + Set ids = new HashSet<>(); + for (RecipeDisplayEntry display : cell.getRecipes()) { + int id = display.id().index(); + require(collected.add(id) && ids.add(id), "Display repeated across native cells"); + JsonObject row = displays.get(id); + require(row != null, "Native cell contains an unmanifested display"); + keys.add(cellKey(row)); + require(ItemStack.isSameItemSameComponents(result(display, context), result(cell.getRecipes().getFirst(), context)), + "Mixed items share a native recipe-book cell"); + } + require(keys.size() == 1, "Unrelated intended groups share a native cell: " + keys); + String key = keys.iterator().next(); + require(actualCells.add(key) && ids.equals(expectedCells.get(key)), "Missing/split native cell: " + key); + cell.selectRecipes(new StackedItemContents(), display -> + display instanceof ShapedCraftingRecipeDisplay shaped ? shaped.width() <= 3 && shaped.height() <= 3 + : !(display instanceof ShapelessCraftingRecipeDisplay shapeless) || shapeless.ingredients().size() <= 9); + require(cell.getSelectedRecipes(RecipeCollection.CraftableStatus.ANY).size() == cell.getRecipes().size(), + "Unlocked variants hidden in native cell"); + } + require(collected.equals(displays.keySet()) && actualCells.equals(expectedCells.keySet()), "Missing or split native cells"); + return new VerifiedBook(book, recipes.size(), displays.size()); + } + + private static String cellKey(JsonObject row) { + return row.get("type").getAsString() + "|" + row.get("output").getAsString(); + } + + static ContextMap context(RegistryAccess registries) { + return new ContextMap.Builder().withParameter(SlotDisplayContext.REGISTRIES, registries) + .withParameter(SlotDisplayContext.FUEL_VALUES, net.minecraft.world.level.block.entity.FuelValues + .vanillaBurnTimes(registries, net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS)) + .create(SlotDisplayContext.CONTEXT); + } + + private static CompoundTag compound(CompoundTag tag, String key) { + return tag.getCompound(key).orElseThrow(() -> new IllegalStateException("Missing native fixture compound: " + key)); + } + + private static ItemStack result(RecipeDisplayEntry display, ContextMap context) { + List results = display.resultItems(context); + require(results.size() == 1 && !results.getFirst().isEmpty(), "Recipe display has no unique native result: " + display.id()); + return results.getFirst(); + } + + private static void render(Minecraft minecraft, ClientRecipeBook book, ContextMap context) throws Exception { List shown = new ArrayList<>(); - Set selected = new HashSet<>(java.util.Arrays.asList("baseelectric:blank_circuit_board", - "baseelectric:integrated_circuit", "baseelectric:control_circuit", "baseelectric:psu", "basepower:item_conveyor")); + Set selected = Set.of("baseelectric:blank_circuit_board", "baseelectric:integrated_circuit", + "baseelectric:control_circuit", "baseelectric:psu", "basepower:item_conveyor"); for (RecipeCollection cell : book.getCollections()) { - String id = cellOutputId(cell); + String id = cellOutputId(cell, context); if (selected.contains(id) || id.startsWith("basepower:item_filter_")) shown.add(cell); } - shown.sort(java.util.Comparator.comparing(RecipeBookGroupingProbe::cellOutputId)); + shown.sort(java.util.Comparator.comparing(cell -> cellOutputId(cell, context))); require(shown.size() == 13, "The thirteen reported circuit/conveyor cells must be separate"); + RecipeBookPage page = new RecipeBookPage(null, () -> 0, false); + Field bookField = RecipeBookPage.class.getDeclaredField("recipeBook"); + bookField.setAccessible(true); + bookField.set(page, book); int width = 1000, height = 180; - RenderTarget target = new com.mojang.blaze3d.pipeline.TextureTarget(width, height, true, Minecraft.ON_OSX); - target.bindWrite(true); - NativeGuiProjection projection = new NativeGuiProjection(width, height, -1000, 1000, 0); - RenderSystem.disableDepthTest(); - try { - RenderSystem.clearColor(0.12F, 0.12F, 0.12F, 1F); - RenderSystem.clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT, Minecraft.ON_OSX); - net.minecraft.world.inventory.CraftingMenu menu = new net.minecraft.world.inventory.CraftingMenu(0, new net.minecraft.world.entity.player.Inventory(null)); + BufferedImage image = NativeGuiProjection.render(minecraft, width, height, 1, 0xFF1E1E1E, graphics -> { + var menu = new net.minecraft.world.inventory.CraftingMenu(0, + new net.minecraft.world.entity.player.Inventory(null, new net.minecraft.world.entity.EntityEquipment())); require(menu.getRecipeBookType() == net.minecraft.world.inventory.RecipeBookType.CRAFTING, "Wrong native crafting menu type"); - var graphics = new net.minecraft.client.gui.GuiGraphics(minecraft, minecraft.renderBuffers().bufferSource()); for (int i = 0; i < shown.size(); i++) { int x = 10 + (i % 5) * 198, y = 12 + (i / 5) * 55; - RecipeButton button = new RecipeButton(); - // Supply the native menu type without opening a world or connecting a player. - for (Field field : RecipeButton.class.getDeclaredFields()) { - if (field.getType() == RecipeCollection.class || field.getType() == RecipeBook.class) { - field.setAccessible(true); - field.set(button, field.getType() == RecipeCollection.class ? shown.get(i) : book); - } else if (field.getType() == net.minecraft.world.inventory.RecipeBookMenu.class) { - field.setAccessible(true); - field.set(button, menu); - } - } + RecipeButton button = new RecipeButton(() -> 0); + button.init(shown.get(i), false, page, context); button.setPosition(x, y); button.render(graphics, -1, -1, 1F); - graphics.drawString(minecraft.font, cellResult(shown.get(i)).getDisplayName(), x + 30, y + 8, 0xFFFFFF, false); + graphics.drawString(minecraft.font, result(shown.get(i).getRecipes().getFirst(), context).getHoverName(), + x + 30, y + 8, 0xFFFFFFFF, false); } - graphics.flush(); - ByteBuffer pixels = ByteBuffer.allocateDirect(width * height * 4); - RenderSystem.readPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - int colored = 0; - for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { - int index = ((height - y - 1) * width + x) * 4; - int rgb = (pixels.get(index) & 255) << 16 | (pixels.get(index + 1) & 255) << 8 | pixels.get(index + 2) & 255; - image.setRGB(x, y, rgb); - if ((rgb & 255) > 80 || ((rgb >>> 16) & 255) > 80) colored++; - } - ImageIO.write(image, "png", new File("recipe-book-cells.png")); - require(colored > 1000, "Native recipe buttons rendered blank"); - } finally { - projection.close(); - target.destroyBuffers(); minecraft.getMainRenderTarget().bindWrite(true); - RenderSystem.enableDepthTest(); + }); + int colored = 0; + for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { + int rgb = image.getRGB(x, y); + if ((rgb & 255) > 80 || ((rgb >>> 16) & 255) > 80) colored++; } + ImageIO.write(image, "png", new File("recipe-book-cells.png")); + require(colored > 1000, "Native recipe buttons rendered blank"); } - private static ItemStack cellResult(RecipeCollection cell) { - return cell.getRecipes().get(0).value().getResultItem(cell.registryAccess()); - } - - private static String cellOutputId(RecipeCollection cell) { - return ForgeRegistries.ITEMS.getKey(cellResult(cell).getItem()).toString(); + private static String cellOutputId(RecipeCollection cell, ContextMap context) { + return BuiltInRegistries.ITEM.getKey(result(cell.getRecipes().getFirst(), context).getItem()).toString(); } private static void require(boolean condition, String message) { diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/TankVisualProbe.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/TankVisualProbe.java index e9e5abe0..2bbf1cab 100644 --- a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/TankVisualProbe.java +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/TankVisualProbe.java @@ -7,20 +7,16 @@ import cyano.poweradvantage.api.simple.TileEntitySimpleFluidMachine; import io.netty.buffer.Unpooled; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.renderer.texture.TextureAtlas; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientLevel; -import com.mojang.blaze3d.platform.GlStateManager; -import com.mojang.blaze3d.systems.RenderSystem; -import com.mojang.blaze3d.pipeline.RenderTarget; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.level.material.FluidState; import net.minecraft.world.level.material.Fluid; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.core.Direction; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.BlockPos; import net.minecraft.core.RegistryAccess; import net.minecraft.core.registries.BuiltInRegistries; @@ -40,12 +36,10 @@ import java.io.File; import java.io.IOException; import java.lang.reflect.Field; -import java.nio.ByteBuffer; import java.util.function.Consumer; /** Checks native neighbour culling and the actual fluid-bar drawing function. */ final class TankVisualProbe { - private static final int GL_COLOR_BUFFER_BIT = 16384, GL_RGBA = 6408, GL_UNSIGNED_BYTE = 5121; private static final int WIDTH = 176, HEIGHT = 240; private static final int PANEL_TOP = 12, GAUGE_X = 80, GAUGE_Y = PANEL_TOP + 9, GAUGE_HEIGHT = 60, TILE = 16; private TankVisualProbe() {} @@ -79,15 +73,15 @@ private static void checkTank(Minecraft minecraft, GUIBlock block, String name, RegistryAccess registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); PacketLevel packetLevel = PacketLevel.withRegistries(registries); var definition = registeredDefinition(block); - var inventory = new Inventory(null); + var inventory = new Inventory(null, new net.minecraft.world.entity.EntityEquipment()); var container = definition.createMenu(1, inventory, client); require(container.getType() == MachineGUIRegistry.TYPE && MachineGUIRegistry.TYPE != null, "Tank GUI is not backed by the registered native container type"); var screen = definition.new GUIContainer(container, inventory, net.minecraft.network.chat.Component.literal(name)) { void renderTank(GuiGraphics graphics) { renderBg(graphics, 0F, -100, -100); } }; - screen.init(minecraft, WIDTH, HEIGHT); - ResourceLocation texture = ResourceLocation.fromNamespaceAndPath("basepower", "textures/gui/container/" + art); + screen.init(WIDTH, HEIGHT); + Identifier texture = Identifier.fromNamespaceAndPath("basepower", "textures/gui/container/" + art); BufferedImage empty = render(minecraft, screen::renderTank); BufferedImage emptyReference = reference(minecraft, texture, null, 0); compare(name, "empty", 0, 0, empty, emptyReference, emptyReference, cases); @@ -148,8 +142,7 @@ private static final class PacketLevel extends ClientLevel { private RegistryAccess registries; private PacketLevel() { - super(null, null, Level.OVERWORLD, null, 0, 0, - () -> net.minecraft.util.profiling.InactiveProfiler.INSTANCE, null, false, 0); + super(null, null, Level.OVERWORLD, null, 0, 0, null, false, 0, 0); throw new AssertionError("Allocate only a registry context, not a connected client world"); } @@ -174,31 +167,34 @@ private static PacketLevel withRegistries(RegistryAccess registries) { private static boolean floorVisible(final BlockState tank) { BlockGetter world = new BlockGetter() { @Override public int getHeight() { return 256; } - @Override public int getMinBuildHeight() { return 0; } + @Override public int getMinY() { return 0; } @Override public BlockEntity getBlockEntity(BlockPos pos) { return null; } @Override public BlockState getBlockState(BlockPos pos) { return pos.equals(BlockPos.ZERO.above()) ? tank : net.minecraft.world.level.block.Blocks.GRASS_BLOCK.defaultBlockState(); } @Override public FluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); } }; - return Block.shouldRenderFace(net.minecraft.world.level.block.Blocks.GRASS_BLOCK.defaultBlockState(), world, BlockPos.ZERO, Direction.UP, BlockPos.ZERO.above()); + return Block.shouldRenderFace(world, BlockPos.ZERO, + net.minecraft.world.level.block.Blocks.GRASS_BLOCK.defaultBlockState(), tank, Direction.UP); } - private static BufferedImage reference(Minecraft minecraft, ResourceLocation texture, Sample sample, int rows) { + private static BufferedImage reference(Minecraft minecraft, Identifier texture, Sample sample, int rows) { return render(minecraft, graphics -> { - graphics.blit(texture, 0, PANEL_TOP, 0, 0, WIDTH, 128); + graphics.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED, + texture, 0, PANEL_TOP, 0, 0, WIDTH, 128, 256, 256); if (sample != null) { - var sprite = minecraft.getTextureAtlas(TextureAtlas.LOCATION_BLOCKS).apply(ResourceLocation.parse(sample.sprite())); - require(sprite.contents().name().equals(ResourceLocation.parse(sample.sprite())), "Missing reference sprite: " + sample.sprite()); + var sprite = minecraft.getAtlasManager().getAtlasOrThrow(net.minecraft.data.AtlasIds.BLOCKS) + .getSprite(Identifier.parse(sample.sprite())); + require(sprite.contents().name().equals(Identifier.parse(sample.sprite())), "Missing reference sprite: " + sample.sprite()); int tint = sample.tint(); // Use Minecraft's full-sprite blit, not the production UV or clipped-tile calculations. for (int y = GAUGE_Y + GAUGE_HEIGHT - rows; y < GAUGE_Y + GAUGE_HEIGHT; y += TILE) { - graphics.blit(GAUGE_X, y, 0, TILE, TILE, sprite, - (tint >>> 16 & 255) / 255F, (tint >>> 8 & 255) / 255F, (tint & 255) / 255F, 1F); + graphics.blitSprite(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED, + sprite, GAUGE_X, y, TILE, TILE, tint | 0xFF000000); } } - RenderSystem.enableBlend(); - graphics.blit(texture, GAUGE_X - 3, GAUGE_Y - 3, 176, 0, 22, 66); + graphics.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED, + texture, GAUGE_X - 3, GAUGE_Y - 3, 176, 0, 22, 66, 256, 256); }); } @@ -231,36 +227,7 @@ private static void compare(String tank, String fluid, int amount, int rows, Buf } private static BufferedImage render(Minecraft minecraft, Consumer draw) { - RenderTarget framebuffer = new com.mojang.blaze3d.pipeline.TextureTarget(WIDTH, HEIGHT, true, Minecraft.ON_OSX); - framebuffer.bindWrite(true); - NativeGuiProjection projection = new NativeGuiProjection(WIDTH, HEIGHT, 1000, 3000, -2000); - RenderSystem.disableDepthTest(); - RenderSystem.enableBlend(); - RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA); - RenderSystem.setShaderColor(1F, 1F, 1F, 1F); - try { - RenderSystem.clearColor(0F, 0F, 0F, 1F); - RenderSystem.clear(GL_COLOR_BUFFER_BIT, Minecraft.ON_OSX); - var graphics = new GuiGraphics(minecraft, minecraft.renderBuffers().bufferSource()); - draw.accept(graphics); - graphics.flush(); - ByteBuffer pixels = ByteBuffer.allocateDirect(WIDTH * HEIGHT * 4); - RenderSystem.readPixels(0, 0, WIDTH, HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, pixels); - BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); - for (int y = 0; y < HEIGHT; y++) { - for (int x = 0; x < WIDTH; x++) { - int index = ((HEIGHT - y - 1) * WIDTH + x) * 4; - image.setRGB(x, y, (pixels.get(index) & 255) << 16 - | (pixels.get(index + 1) & 255) << 8 | pixels.get(index + 2) & 255); - } - } - return image; - } finally { - projection.close(); - framebuffer.destroyBuffers(); - minecraft.getMainRenderTarget().bindWrite(true); - RenderSystem.enableDepthTest(); - } + return NativeGuiProjection.render(minecraft, WIDTH, HEIGHT, 1, 0xFF000000, draw); } private static void require(boolean condition, String message) { diff --git a/test-rig/client-resources/prepare-eclipse-client.ps1 b/test-rig/client-resources/prepare-eclipse-client.ps1 new file mode 100644 index 00000000..8ea12abf --- /dev/null +++ b/test-rig/client-resources/prepare-eclipse-client.ps1 @@ -0,0 +1,61 @@ +param( + [string]$RepositoryRoot = (Join-Path $PSScriptRoot '../..'), + [string]$AssetsDirectory = (Join-Path $env:APPDATA '.minecraft/assets'), + [Parameter(Mandatory = $true)][string]$Evidence +) +$ErrorActionPreference = 'Stop' +$RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path +$AssetsDirectory = (Resolve-Path -LiteralPath $AssetsDirectory).Path +$Evidence = [IO.Path]::GetFullPath($Evidence) +if (Test-Path -LiteralPath $Evidence) { throw 'Refusing to replace Eclipse preparation evidence.' } +New-Item -ItemType Directory -Path $Evidence | Out-Null +$index = Join-Path $AssetsDirectory 'indexes/29.json' +if ((Get-FileHash -LiteralPath $index -Algorithm SHA1).Hash -ine '39CAFD4FD3EDC4A1E3C2F20BD83EA3382DAB43F1') { + throw 'The human client requires the actual Minecraft 1.21.11 asset index 29.' +} +$manifest = Get-Content -LiteralPath $index -Raw | ConvertFrom-Json +$objects = @($manifest.objects.PSObject.Properties.Value | Sort-Object hash -Unique) +foreach ($entry in $objects) { + $path = Join-Path $AssetsDirectory ('objects/' + $entry.hash.Substring(0, 2) + '/' + $entry.hash) + if ((Get-Item -LiteralPath $path).Length -ne $entry.size -or (Get-FileHash -LiteralPath $path -Algorithm SHA1).Hash -ine $entry.hash) { + throw "Missing or changed native client asset: $($entry.hash)" + } +} +$records = @() +foreach ($name in 'runClient.launch','runClientAllAdvantage.launch') { + $path = Join-Path $RepositoryRoot $name + [xml]$launch = Get-Content -LiteralPath $path -Raw + $node = @($launch.launchConfiguration.stringAttribute | Where-Object { $_.key -eq 'org.eclipse.jdt.launching.PROGRAM_ARGUMENTS' }) + if ($node.Count -ne 1) { throw "Expected one argument element in $name" } + $program = $node[0].value + if ($program -notmatch '1\.21\.11-61\.1\.0' -or $program -notmatch '--launchTarget forge_userdev_client\b' -or + $program -match '--(?:server|port|world)\b|advantageworks\.clientResources') { + throw "Not a normal native human-client launch: $name" + } + foreach ($argument in '--assetIndex','--assetsDir') { + if ([regex]::Matches($program, ([regex]::Escape($argument) + '\s+(?:"[^"]+"|\S+)')).Count -ne 1) { + throw "Missing or repeated $argument in $name" + } + } + $before = (Get-FileHash -LiteralPath $path).Hash + Copy-Item -LiteralPath $path -Destination (Join-Path $Evidence $name) + $program = [regex]::Replace($program, '--assetIndex\s+(?:"[^"]+"|\S+)', '--assetIndex 29') + $program = [regex]::Replace($program, '--assetsDir\s+(?:"[^"]+"|\S+)', + [Text.RegularExpressions.MatchEvaluator]{ param($match) '--assetsDir "' + $AssetsDirectory + '"' }) + if ($program -match '\{(?:asset_index|assets_root)\}') { throw 'Unresolved native asset argument.' } + $node[0].SetAttribute('value', $program) + $settings = [Xml.XmlWriterSettings]::new() + $settings.Encoding = [Text.UTF8Encoding]::new($false) + $settings.Indent = $true + $writer = [Xml.XmlWriter]::Create($path, $settings) + try { $launch.Save($writer) } finally { $writer.Dispose() } + $records += [ordered]@{ path=$path; beforeSHA256=$before; afterSHA256=(Get-FileHash -LiteralPath $path).Hash } +} +[ordered]@{ + status='PREPARED_NORMAL_HUMAN_CLIENT_NOT_A_GAMEPLAY_PASS' + minecraft='1.21.11'; forge='61.1.0'; assetIndex=29 + assetIndexSHA1=(Get-FileHash -LiteralPath $index -Algorithm SHA1).Hash + assetsDirectory=$AssetsDirectory; verifiedAssetObjects=$objects.Count; launches=$records + note='Only generated argument XML changed; no root build/default-profile/source or jar edits. Reapply after Gradle regenerates launches.' +} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $Evidence 'receipt.json') -Encoding utf8 +Write-Output "Prepared normal and combined Eclipse client arguments with $($objects.Count) verified native assets. No client/world started. Evidence: $Evidence" diff --git a/test-rig/client-resources/resources/META-INF/mods.toml b/test-rig/client-resources/resources/META-INF/mods.toml index d6f0ca8d..5ccf1790 100644 --- a/test-rig/client-resources/resources/META-INF/mods.toml +++ b/test-rig/client-resources/resources/META-INF/mods.toml @@ -1,6 +1,7 @@ modLoader="javafml" -loaderVersion="[52,53)" +loaderVersion="[61,62)" license="GPL-3.0-only" +showAsResourcePack=false [[mods]] modId="advantageclientresources" @@ -11,20 +12,20 @@ description='''Build-only model, texture and translation checks. Never distribut [[dependencies.advantageclientresources]] modId="basepower" mandatory=true -versionRange="[2.4.14.121011]" +versionRange="[2.4.15.121111]" ordering="AFTER" side="CLIENT" [[dependencies.advantageclientresources]] modId="forge" mandatory=true -versionRange="[52.1.0]" +versionRange="[61.1.0]" ordering="NONE" side="CLIENT" [[dependencies.advantageclientresources]] modId="minecraft" mandatory=true -versionRange="[1.21.1]" +versionRange="[1.21.11]" ordering="NONE" side="CLIENT" diff --git a/test-rig/client-resources/resources/pack.mcmeta b/test-rig/client-resources/resources/pack.mcmeta index 491ce5fa..c41e457a 100644 --- a/test-rig/client-resources/resources/pack.mcmeta +++ b/test-rig/client-resources/resources/pack.mcmeta @@ -1,6 +1,7 @@ { "pack": { "description": "Build-only Advantage client resource checks", - "pack_format": 32 + "min_format": [94,1], + "max_format": [94,1] } } diff --git a/test-rig/client-resources/run.ps1 b/test-rig/client-resources/run.ps1 index 54886e09..0e002c69 100644 --- a/test-rig/client-resources/run.ps1 +++ b/test-rig/client-resources/run.ps1 @@ -1,6 +1,13 @@ param([Parameter(Mandatory = $true)][string]$JavaHome, [ValidateSet('runClient.launch', 'runClientAllAdvantage.launch')][string]$LaunchProfile = 'runClient.launch', - [ValidateSet('Gradle', 'Eclipse')][string]$OutputSource = 'Gradle', + [ValidateSet('Gradle', 'Eclipse', 'Frozen')][string]$OutputSource = 'Gradle', + [string]$FrozenProfile, + [string]$FrozenProfileSHA256, + [string]$ProbeJar, + [string]$AssetsDirectory, + [string]$AssetIndex, + [string]$BootstrapCacheDirectory, + [switch]$LoosePowerDiagnostic, [switch]$RequireExtensions, [switch]$ChildRendererGeometry, [switch]$MachineGuiLayouts, @@ -30,22 +37,22 @@ function Assert-NativeMetadata([string]$Metadata, [string]$Id) { # Match the known single-mod header, not dependency modId/versionRange fields. $header = [regex]::Match($Metadata, '(?ms)^\[\[mods\]\]\s*(.*?)(?=^\[|\z)').Groups[1].Value if ($header -notmatch ('(?m)^modId\s*=\s*"' + [regex]::Escape($Id) + '"[ \t]*(?:#[^\r\n]*)?\r?$') -or - $header -notmatch '(?m)^version\s*=\s*"[^"\r\n]*\.121011"[ \t]*(?:#[^\r\n]*)?\r?$' -or - $Metadata -notmatch '(?m)^loaderVersion\s*=\s*"\[52,') { throw "Not a native Forge 52 candidate: $Id" } - $expected = @{ basepower = '2.4.14.121011'; basesteam = '2.2.15.121011'; baseelectric = '2.2.15.121011' } + $header -notmatch '(?m)^version\s*=\s*"[^"\r\n]*\.121111"[ \t]*(?:#[^\r\n]*)?\r?$' -or + $Metadata -notmatch '(?m)^loaderVersion\s*=\s*"\[61,') { throw "Not a native Forge 61 candidate: $Id" } + $expected = @{ basepower = '2.4.15.121111'; basesteam = '2.2.16.121111'; baseelectric = '2.2.16.121111'; basemetals = '3.1.2.121111'; orespawn = '4.1.0.121111' } if ($expected.ContainsKey($Id) -and $header -notmatch ('(?m)^version\s*=\s*"' + [regex]::Escape($expected[$Id]) + '"[ \t]*(?:#[^\r\n]*)?\r?$')) { throw "Unexpected native candidate version for $Id; expected $($expected[$Id])" } } function Assert-NativeLaunch([hashtable]$Environment, [hashtable]$Attributes) { - if ($Environment.MC_VERSION -ne '1.21.1' -or $Environment.FORGE_VERSION -ne '52.1.0' -or - $Environment.MCP_MAPPINGS -ne 'official_1.21.1' -or $Environment.MCP_VERSION -ne '20240808.132146' -or - $Environment.target -ne 'fmluserdevclient') { throw 'Regenerate the native 1.21.1 / Forge 52.1.0 official client launch.' } + if ($Environment.MC_VERSION -ne '1.21.11' -or $Environment.FORGE_VERSION -ne '61.1.0' -or + $Environment.MCP_MAPPINGS -ne 'official_1.21.11' -or $Environment.MCP_VERSION -ne '20251223.124241' -or + $Environment.target -ne 'fmluserdevclient') { throw 'Regenerate the native 1.21.11 / Forge 61.1.0 official client launch.' } $program = $Attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] - if ($program -notmatch '1\.21\.1-52\.1\.0' -or + if ($program -notmatch '1\.21\.11-61\.1\.0' -or $program -notmatch '--main net\.minecraftforge\.bootstrap\.ForgeBootstrap\b' -or $program -notmatch '--launchTarget forge_userdev_client\b' -or - $program -match '1\.(?:13\.2|14\.4|15\.2|16\.5|17\.1|18\.2|19\.4|20\.1)|--(?:server|port|world)\b' -or + $program -match '1\.(?:13\.2|14\.4|15\.2|16\.5|17\.1|18\.2|19\.4|20\.[16]|21\.1(?!1))|--(?:server|port|world)\b|\{(?:asset_index|assets_root)\}' -or @([regex]::Matches($program, '--gameDir\s+\.(?=\s|$)')).Count -ne 1 -or @([regex]::Matches($program, '--gameDir\b')).Count -ne 1 -or $Attributes['org.eclipse.jdt.launching.MAIN_TYPE'] -ne 'net.minecraftforge.launcher.Main') { @@ -53,18 +60,44 @@ function Assert-NativeLaunch([hashtable]$Environment, [hashtable]$Attributes) { } } function Assert-NativeArchive([string]$Path) { - if ($Path -match '(?:forge|client-extra)-1\.(?:13\.2|14\.4|15\.2|16\.5|17\.1|18\.2|19\.4)|[\\/]PA(?:11[3456789]|1201)[\\/]') { throw "Legacy runtime archive: $Path" } + if ($Path -match '(?:forge|client-extra)-1\.(?:13\.2|14\.4|15\.2|16\.5|17\.1|18\.2|19\.4|20\.[16]|21\.1(?!1))|[\\/]PA(?:11[3456789]|120[16]|1211)[\\/]') { throw "Legacy runtime archive: $Path" } } function Quote-Argument([string]$Value) { '"' + [regex]::Replace([regex]::Replace($Value, '(\\*)"', '$1$1\"'), '(\\+)$', '$1$1') + '"' } -$probe = Join-Path $project 'build/client-resource-probe/advantageclientresources-deobf.jar' +function Read-FrozenProfile([string]$Path, [string]$ExpectedHash) { + if ($ExpectedHash -cnotmatch '^[A-Fa-f0-9]{64}$' -or (Get-FileHash -LiteralPath $Path).Hash -ine $ExpectedHash) { + throw 'Frozen profile SHA-256 is required and must match.' + } + $profile = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($profile.mode -ne 'packaged' -or $profile.minecraftVersion -ne '1.21.11' -or $profile.forge.version -ne '1.21.11-61.1.0') { + throw 'Frozen profile is not the native packaged target.' + } + $mods = @{} + foreach ($mod in $profile.mods) { + if ($mod.id -eq 'advantageworkstest') { continue } + if ($mod.id -notin @('basepower','basesteam','baseelectric','basemetals','orespawn') -or $mods.ContainsKey($mod.id)) { + throw "Unexpected/duplicate frozen mod: $($mod.id)" + } + if ($mod.sha256 -cnotmatch '^[A-Fa-f0-9]{64}$' -or (Get-FileHash -LiteralPath $mod.path).Hash -ine $mod.sha256) { + throw "Frozen input SHA-256 mismatch: $($mod.id)" + } + Assert-NativeMetadata (Read-JarMetadata $mod.path) $mod.id + $mods[$mod.id] = $mod + } + if ($mods.Count -ne 5) { throw 'Frozen combined profile must contain all five native mods.' } + return $mods +} +$probe = if ($ProbeJar) { (Resolve-Path -LiteralPath $ProbeJar).Path } + else { Join-Path $project 'build/client-resource-probe/advantageclientresources-deobf.jar' } $runtime = Join-Path $project ('run-client-resource-runs/' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) -$inputReport = [ordered]@{ minecraft = '1.21.1'; forge = '52.1.0'; mappings = 'official_1.21.1'; mcp = '20240808.132146'; qualification = 'UNQUALIFIED'; output_source = $OutputSource; launch_profile = $LaunchProfile; require_extensions = [bool]$RequireExtensions; child_renderer_geometry = [bool]$ChildRendererGeometry } +$inputReport = [ordered]@{ minecraft = '1.21.11'; forge = '61.1.0'; mappings = 'official_1.21.11'; mcp = '20251223.124241'; qualification = 'UNQUALIFIED'; output_source = $OutputSource; launch_profile = $LaunchProfile; require_extensions = [bool]$RequireExtensions; child_renderer_geometry = [bool]$ChildRendererGeometry } $inputReport.machine_gui_layouts = [bool]$MachineGuiLayouts +$inputReport.loose_power_diagnostic = [bool]$LoosePowerDiagnostic $phase = 'launch-preflight' New-Item -ItemType Directory -Path $runtime | Out-Null try { +if ($LoosePowerDiagnostic -and $OutputSource -ne 'Frozen') { throw 'Loose-Power diagnostics require a hash-bound frozen dependency profile.' } if ($ChildRendererGeometry -and -not $RequireExtensions) { throw 'Child renderer geometry requires -RequireExtensions.' } if (-not (Test-Path -LiteralPath $java)) { throw "Java 21 runtime not found: $java" } if ((Get-Content -LiteralPath (Join-Path $JavaHome 'release') -Raw) -notmatch '(?m)^JAVA_VERSION="21(?:\.|\+|")') { throw 'Supply Java 21 for the native client.' } @@ -76,8 +109,8 @@ $inputReport.java = Get-Record $java $inputReport.launch = Get-Record (Join-Path $project $LaunchProfile) $inputReport.probe = Get-Record $probe $probeMetadata = Read-JarMetadata $probe -if ($probeMetadata -notmatch 'loaderVersion="\[52,53\)"' -or $probeMetadata -notmatch 'versionRange="\[1\.21\.1\]"' -or - $probeMetadata -notmatch 'versionRange="\[52\.1\.0\]"' -or $probeMetadata -notmatch 'versionRange="\[2\.4\.14\.121011\]"') { throw 'Rebuild the native 1.21.1 probe; do not substitute a legacy probe.' } +if ($probeMetadata -notmatch 'loaderVersion="\[61,62\)"' -or $probeMetadata -notmatch 'versionRange="\[1\.21\.11\]"' -or + $probeMetadata -notmatch 'versionRange="\[61\.1\.0\]"' -or $probeMetadata -notmatch 'versionRange="\[2\.4\.15\.121111\]"') { throw 'Rebuild the native 1.21.11 probe; do not substitute a legacy probe.' } Copy-Item -LiteralPath (Join-Path $project $LaunchProfile) -Destination (Join-Path $runtime 'launch.xml') $environment = @{} foreach ($map in $launch.launchConfiguration.mapAttribute) { @@ -85,9 +118,50 @@ foreach ($map in $launch.launchConfiguration.mapAttribute) { foreach ($entry in $map.mapEntry) { $environment[$entry.key] = $entry.value } } } +if ($AssetsDirectory -or $AssetIndex) { + if (-not $AssetsDirectory -or $AssetIndex -cnotmatch '^\d+$') { throw 'Supply both concrete native assets directory and index.' } + $AssetsDirectory = (Resolve-Path -LiteralPath $AssetsDirectory).Path + $indexPath = Join-Path $AssetsDirectory "indexes/$AssetIndex.json" + if ($AssetIndex -ne '29' -or (Get-FileHash -LiteralPath $indexPath -Algorithm SHA1).Hash -ine '39cafd4fd3edc4a1e3c2f20bd83ea3382dab43f1') { + throw 'Assets index does not match the native 1.21.11 manifest.' + } + $program = $attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] + $program = $program.Replace('{asset_index}', $AssetIndex).Replace('{assets_root}', ('"'+$AssetsDirectory+'"')) + $attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] = $program + $inputReport.asset_index = Get-Record $indexPath +} +if ($BootstrapCacheDirectory) { + $cache = [IO.Path]::GetFullPath($BootstrapCacheDirectory) + New-Item -ItemType Directory -Path $cache -Force | Out-Null + $program = $attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] + if ([regex]::Matches($program, '--cache\s+(?:"[^"]+"|\S+)').Count -ne 1) { throw 'Expected one native bootstrap cache argument.' } + $attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] = [regex]::Replace($program, '--cache\s+(?:"[^"]+"|\S+)', ('--cache "'+$cache+'"')) + $inputReport.bootstrap_cache = $cache +} Assert-NativeLaunch $environment $attributes $modOutput = Join-Path $runtime 'basepower' New-Item -ItemType Directory -Path $modOutput,(Join-Path $runtime 'mods') -Force | Out-Null +if ($OutputSource -eq 'Frozen') { + if (-not $FrozenProfile -or -not $BootstrapCacheDirectory -or -not $RequireExtensions) { + throw 'Frozen runs require the exact combined profile and an explicit isolated bootstrap cache.' + } + $frozenMods = Read-FrozenProfile $FrozenProfile $FrozenProfileSHA256 + $inputReport.frozen_profile = Get-Record $FrozenProfile + $inputReport.frozen_mods = @() + foreach ($id in $frozenMods.Keys) { + if ($LoosePowerDiagnostic -and $id -eq 'basepower') { continue } + $mod = $frozenMods[$id] + $destination = Join-Path $runtime ('mods/' + [IO.Path]::GetFileName($mod.path)) + Copy-Item -LiteralPath $mod.path -Destination $destination + $record = Get-Record $destination + if ($record.sha256 -ine $mod.sha256) { throw "Frozen mod changed during snapshot: $id" } + $record.origin_path = $mod.path + $record.id = $id + $inputReport.frozen_mods += $record + } + $inputReport.candidate_note = 'Runtime loads exact SHA-bound player jars from the frozen profile; no loose production classes/resources.' +} +if ($OutputSource -ne 'Frozen' -or $LoosePowerDiagnostic) { if ($OutputSource -eq 'Eclipse') { Copy-Item -Path (Join-Path $project 'bin/main/*') -Destination $modOutput -Recurse } else { @@ -108,11 +182,13 @@ $outputFiles = @(Get-ChildItem -LiteralPath $modOutput -Recurse -File | Sort-Obj if (@($outputFiles | Where-Object { $_.path -like '*.class' }).Count -eq 0) { throw 'Selected output has no compiled production classes.' } $outputFiles | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $runtime 'production-files.json') -Encoding utf8 $inputReport.production_files = Get-Record (Join-Path $runtime 'production-files.json') -if (-not $PowerCandidate) { $PowerCandidate = Join-Path $project 'build/libs/basePower-2.4.14.121011.jar' } +if (-not $PowerCandidate) { $PowerCandidate = Join-Path $project 'build/libs/basePower-2.4.15.121111.jar' } $PowerCandidate = (Resolve-Path -LiteralPath $PowerCandidate).Path Assert-NativeMetadata (Read-JarMetadata $PowerCandidate) 'basepower' $inputReport.power_candidate = Get-Record $PowerCandidate $inputReport.candidate_note = 'Jar provenance only; runtime uses the separately hashed selected production classes/resources.' +if ($LoosePowerDiagnostic) { $inputReport.candidate_note = 'DIAGNOSTIC ONLY: snapshotted loose Power output with SHA-bound frozen dependency jars. This is not qualification of a frozen Power player jar.' } +} Copy-Item -LiteralPath $probe -Destination (Join-Path $runtime 'mods/advantageclientresources.jar') if ((Get-FileHash -LiteralPath (Join-Path $runtime 'mods/advantageclientresources.jar')).Hash -ne $inputReport.probe.sha256) { throw 'Probe changed during snapshot.' } if ($RecipeBookFixtures) { @@ -127,10 +203,27 @@ if ($RecipeBookFixtures) { } $fixtureRecords = @() foreach ($packet in $packets) { + $manifestPath = [IO.Path]::ChangeExtension($packet.FullName, '.json') + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ($manifest.bookPacketType -ne 'ClientboundRecipeBookAddPacket') { throw 'Recipe fixture is not the native display-packet protocol.' } + $hasTags = $manifest.tagsPacketType -eq 'ClientboundUpdateTagsPacket' -and + $manifest.itemTagCount -gt 0 -and $manifest.tagsPacketFile -match '^tags-[a-z-]+\.bin$' + if (-not $hasTags -and -not $LoosePowerDiagnostic) { + throw 'Recipe fixture requires both native book-display and item-tag packets.' + } + if ($hasTags) { + $tagsPath = Join-Path $fixtures $manifest.tagsPacketFile + if ($manifest.tagsPacketSha256 -notmatch '^[A-Fa-f0-9]{64}$' -or + (Get-FileHash -LiteralPath $tagsPath).Hash -ine $manifest.tagsPacketSha256) { throw 'Native tag packet SHA-256 mismatch.' } + $fixtureRecords += Get-Record $tagsPath + Copy-Item -LiteralPath $tagsPath -Destination $destination + } else { + $inputReport.recipe_tags = 'MISSING_DIAGNOSTIC_EXPECTED_TO_FAIL_NATIVE_RECIPE_SCOPE' + } $fixtureRecords += Get-Record $packet.FullName - $fixtureRecords += Get-Record ([IO.Path]::ChangeExtension($packet.FullName, '.json')) + $fixtureRecords += Get-Record $manifestPath Copy-Item -LiteralPath $packet.FullName -Destination $destination - Copy-Item -LiteralPath ([IO.Path]::ChangeExtension($packet.FullName, '.json')) -Destination $destination + Copy-Item -LiteralPath $manifestPath -Destination $destination } $registrySource = Join-Path $fixtures 'AdvantageWorksMigration/level.dat' $fixtureRecords += Get-Record $registrySource @@ -145,11 +238,15 @@ $archives = @(foreach ($entry in $classpathNode.listEntry) { $archiveRecords = @() $launchArchives = @() $runtimeCandidates = @{} +if ($OutputSource -eq 'Frozen') { + foreach ($record in $inputReport.frozen_mods) { $runtimeCandidates[$record.id] = $record } +} foreach ($archive in $archives) { Assert-NativeArchive $archive $record = Get-Record $archive $metadata = if ([IO.Path]::GetExtension($archive) -eq '.jar') { Read-JarMetadata $archive } $modHeader = if ($metadata) { [regex]::Match($metadata, '(?ms)^\[\[mods\]\]\s*(.*?)(?=^\[|\z)').Groups[1].Value } + if ($OutputSource -eq 'Frozen' -and $modHeader -match '(?m)^modId\s*=\s*"(?:basepower|basesteam|baseelectric|basemetals|orespawn)"') { continue } if ($modHeader -match '(?m)^modId\s*=\s*"(?:basesteam|baseelectric|basemetals|orespawn)"') { # Never read mutable sibling build outputs while the client is running. $snapshotDirectory = Join-Path $runtime 'runtime-archives' @@ -174,14 +271,15 @@ foreach ($archive in $archives) { $archives = $launchArchives $inputReport.archives = $archiveRecords $inputReport.runtime_candidates = $runtimeCandidates -if (-not ($archives -match 'forge-1\.21\.1-52\.1\.0\.jar$')) { throw 'Missing actual native mapped Forge runtime.' } +if (-not ($archives -match 'forge-1\.21\.11-61\.1\.0\.jar$')) { throw 'Missing actual native mapped Forge runtime.' } if ($RequireExtensions -and (-not $runtimeCandidates.ContainsKey('basesteam') -or -not $runtimeCandidates.ContainsKey('baseelectric'))) { throw 'Combined launch must explicitly contain both native expansion candidates.' } $arguments = @([regex]::Matches($attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'], '"[^"]*"|\S+') | ForEach-Object { $_.Value.Trim('"') }) $vmArguments = @([regex]::Matches($attributes['org.eclipse.jdt.launching.VM_ARGUMENTS'], '"[^"]*"|\S+') | ForEach-Object { $_.Value.Trim('"') }) if ($RequireExtensions) { $vmArguments += '-Dadvantageworks.requireExtensions=true' } if ($ChildRendererGeometry) { $vmArguments += '-Dadvantageworks.childRendererGeometry=true' } if ($MachineGuiLayouts) { $vmArguments += '-Dadvantageworks.machineGuiLayouts=true' } -$environment.MOD_CLASSES = "basepower%%$modOutput;basepower%%$modOutput" +if ($OutputSource -eq 'Frozen' -and -not $LoosePowerDiagnostic) { $environment.MOD_CLASSES = '' } +else { $environment.MOD_CLASSES = "basepower%%$modOutput;basepower%%$modOutput" } $inputReport.environment = $environment $inputReport.program_arguments = $arguments $inputReport.vm_arguments = $vmArguments @@ -214,7 +312,7 @@ $resultPath = Join-Path $runtime 'client-resources.json' if (-not (Test-Path -LiteralPath $resultPath)) { throw "Client did not produce a result; inspect $runtime/logs" } $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json if ($result.status -ne 'PASS') { throw "Client resource check failed: $($result.error); inspect $runtime" } -if ($result.minecraft -ne '1.21.1' -or $result.forge -ne '52.1.0') { throw 'Result is not native 1.21.1 / Forge 52.1.0 evidence.' } +if ($result.minecraft -ne '1.21.11' -or $result.forge -ne '61.1.0') { throw 'Result is not native 1.21.11 / Forge 61.1.0 evidence.' } if ($result.tank_gauge_pixel_status -ne 'PASS' -or @($result.tank_gauge_pixels).Count -ne 26) { throw 'Full fluid-gauge pixel checks did not pass; a single-pixel probe is not acceptable.' } @@ -231,6 +329,12 @@ if ($ChildRendererGeometry) { $inputReport.child_renderer_geometry_status = $res foreach ($record in $archiveRecords) { if ((Get-FileHash -LiteralPath $record.path).Hash -ne $record.sha256) { throw "Dependency changed during the run: $($record.path)" } } +if ($OutputSource -eq 'Frozen') { + foreach ($record in $inputReport.frozen_mods) { + if ((Get-FileHash -LiteralPath $record.path).Hash -cne $record.sha256 -or + (Get-FileHash -LiteralPath $record.origin_path).Hash -cne $record.sha256) { throw "Frozen input changed: $($record.id)" } + } +} $log = Get-Content -LiteralPath (Join-Path $runtime 'logs/latest.log') -Raw $namespaces = if ($RequireExtensions) { '(?:basepower|basesteam|baseelectric)' } else { 'basepower' } if ($log -match "Unable to load model:.*${namespaces}:|Exception loading blockstate definition:.*${namespaces}:|\[FML\.TEXTURE_ERRORS/\]") { @@ -240,7 +344,8 @@ Write-Output "PASS: $($result.items) items, $($result.block_states) block states Write-Output "Fluid gauges: all 26 rendered tank cases matched the reference pixels." if ($MachineGuiLayouts) { Write-Output "Required-caption GUI contract v2: $($result.machine_gui_text_checks) text cases passed; $($result.machine_gui_negative_controls_rejected) deliberately broken controls rejected. Images retained." } if ($ChildRendererGeometry) { Write-Output 'Additional renderer geometry: worldless scope passed; hydroturbine orientation/spin still requires a disposable-world test. This is not pixel/manual acceptance.' } -$inputReport.qualification = 'NATIVE_CLIENT_RESOURCE_PASS' +$inputReport.qualification = if ($LoosePowerDiagnostic) { 'DIAGNOSTIC_LOOSE_POWER_CLIENT_PASS_NOT_FROZEN_CANDIDATE_QUALIFICATION' } + else { 'NATIVE_CLIENT_RESOURCE_PASS' } } catch { # Preserve native partial results and all images; record launcher failures separately. [ordered]@{ status = 'FAIL'; phase = $phase; error = $_.Exception.Message } | ConvertTo-Json | diff --git a/test-rig/client-resources/test-geometry-contract.ps1 b/test-rig/client-resources/test-geometry-contract.ps1 index 258f53c3..7f8fb5d1 100644 --- a/test-rig/client-resources/test-geometry-contract.ps1 +++ b/test-rig/client-resources/test-geometry-contract.ps1 @@ -6,6 +6,11 @@ param( $ErrorActionPreference = 'Stop' $RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path $TargetRoot = (Resolve-Path -LiteralPath $TargetRoot).Path +$targetMetadata = Get-Content -LiteralPath (Join-Path $TargetRoot 'resources/META-INF/mods.toml') -Raw +if ($targetMetadata.Contains('loaderVersion="[61,62)"')) { + & (Join-Path $PSScriptRoot 'test-native-geometry-contract.ps1') -RepositoryRoot $RepositoryRoot -TargetRoot $TargetRoot -NativeSourcesJar $NativeSourcesJar + return +} $checks = 0 function Assert-GeometryContract([bool]$Condition, [string]$Message) { if (-not $Condition) { throw $Message } diff --git a/test-rig/client-resources/test-launcher.ps1 b/test-rig/client-resources/test-launcher.ps1 index 586030d2..2b9fef69 100644 --- a/test-rig/client-resources/test-launcher.ps1 +++ b/test-rig/client-resources/test-launcher.ps1 @@ -62,6 +62,13 @@ function Test-GuiEvidenceRootContract([string]$RunnerSource) { } Test-GuiEvidenceRootContract (Get-Content -LiteralPath $script -Raw) +$projectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '../..')).Path +$build = Get-Content -LiteralPath (Join-Path $projectRoot 'build.gradle') -Raw +if (-not $build.Contains("'test-rig/profiles/native12111/dev-core.json'") -or + $build.Contains("'test-rig/profiles/native1211/dev-core.json'")) { + throw 'The production combined launch must default to native12111, not rely on a CLI profile override.' +} +$checks++ if ($CrossVersionEvidenceRoots) { $workspace = if ($WorkspaceRoot) { (Resolve-Path -LiteralPath $WorkspaceRoot).Path } @@ -76,33 +83,38 @@ if ($CrossVersionEvidenceRoots) { $native = @' modLoader="javafml" -loaderVersion="[52,)" # native Forge +loaderVersion="[61,)" # native Forge [[mods]] # mandatory header modId="basepower" # inline comment -version="2.4.14.121011" # native candidate +version="2.4.15.121111" # native candidate [[dependencies.basepower]] modId="minecraft" mandatory=true -versionRange="[1.21.1]" +versionRange="[1.21.11]" '@ Assert-NativeMetadata $native 'basepower' $checks++ -Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.14.121011','2.4.4.113021')) 'basepower' } -Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.14.121011','2.4.5.114041')) 'basepower' } -Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.14.121011','2.4.5.115021')) 'basepower' } -Assert-Throws { Assert-NativeMetadata ($native.Replace('[52,)','[50,)')) 'basepower' } -Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.14.121011','2.4.7.116051')) 'basepower' } -Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.14.121011','${version}')) 'basepower' } +foreach ($old in '2.4.4.113021','2.4.5.114041','2.4.5.115021','2.4.7.116051','2.4.14.121011','${version}') { + Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.15.121111',$old)) 'basepower' } +} +Assert-Throws { Assert-NativeMetadata ($native.Replace('[61,)','[52,)')) 'basepower' } Assert-Throws { Assert-NativeMetadata $native 'baseelectric' } Assert-Throws { Assert-NativeMetadata '' 'basepower' } foreach ($id in 'basesteam','baseelectric') { - $expansion = $native.Replace('basepower', $id).Replace('2.4.14.121011','2.2.15.121011') + $expansion = $native.Replace('basepower', $id).Replace('2.4.15.121111','2.2.16.121111') Assert-NativeMetadata $expansion $id $checks++ - Assert-Throws { Assert-NativeMetadata ($expansion.Replace('2.2.15.121011','2.2.6.115021')) $id } + Assert-Throws { Assert-NativeMetadata ($expansion.Replace('2.2.16.121111','2.2.6.115021')) $id } + Assert-Throws { Assert-NativeMetadata ($expansion.Replace('2.2.16.121111','2.2.15.121011')) $id } +} +foreach ($dependency in @{ basemetals='3.1.2.121111'; orespawn='4.1.0.121111' }.GetEnumerator()) { + $metadata = $native.Replace('basepower', $dependency.Key).Replace('2.4.15.121111', $dependency.Value) + Assert-NativeMetadata $metadata $dependency.Key + $checks++ + Assert-Throws { Assert-NativeMetadata ($metadata.Replace($dependency.Value, $dependency.Value.Replace('121111','121011'))) $dependency.Key } } -$environment = @{ MC_VERSION = '1.21.1'; FORGE_VERSION = '52.1.0'; MCP_MAPPINGS = 'official_1.21.1'; MCP_VERSION = '20240808.132146'; target = 'fmluserdevclient' } -$attributes = @{ 'org.eclipse.jdt.launching.PROGRAM_ARGUMENTS' = '--main net.minecraftforge.bootstrap.ForgeBootstrap --cache D:\native\1.21.1-52.1.0 -- --launchTarget forge_userdev_client --gameDir .'; 'org.eclipse.jdt.launching.MAIN_TYPE' = 'net.minecraftforge.launcher.Main' } +$environment = @{ MC_VERSION = '1.21.11'; FORGE_VERSION = '61.1.0'; MCP_MAPPINGS = 'official_1.21.11'; MCP_VERSION = '20251223.124241'; target = 'fmluserdevclient' } +$attributes = @{ 'org.eclipse.jdt.launching.PROGRAM_ARGUMENTS' = '--main net.minecraftforge.bootstrap.ForgeBootstrap --cache D:\native\1.21.11-61.1.0 -- --launchTarget forge_userdev_client --gameDir .'; 'org.eclipse.jdt.launching.MAIN_TYPE' = 'net.minecraftforge.launcher.Main' } Assert-NativeLaunch $environment $attributes $checks++ foreach ($key in 'MC_VERSION','FORGE_VERSION','MCP_MAPPINGS','MCP_VERSION','target') { @@ -110,7 +122,7 @@ foreach ($key in 'MC_VERSION','FORGE_VERSION','MCP_MAPPINGS','MCP_VERSION','targ $invalid[$key] = 'legacy' Assert-Throws { Assert-NativeLaunch $invalid $attributes } } -foreach ($argument in '--server localhost','--port 25565','--gameDir D:\protected','--world protected','--cache D:\native\1.14.4-28.2.26') { +foreach ($argument in '--server localhost','--port 25565','--gameDir D:\protected','--world protected','--cache D:\native\1.14.4-28.2.26','--cache D:\native\1.21.1-52.1.0','--assetIndex {asset_index}','--assetsDir {assets_root}') { $invalid = $attributes.Clone() $invalid['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] += ' ' + $argument Assert-Throws { Assert-NativeLaunch $environment $invalid } @@ -118,9 +130,9 @@ foreach ($argument in '--server localhost','--port 25565','--gameDir D:\protecte $invalid = $attributes.Clone() $invalid['org.eclipse.jdt.launching.MAIN_TYPE'] = 'net.minecraft.client.main.Main' Assert-Throws { Assert-NativeLaunch $environment $invalid } -Assert-NativeArchive 'D:\native\forge-1.21.1-52.1.0.jar' +Assert-NativeArchive 'D:\native\forge-1.21.11-61.1.0.jar' $checks++ -foreach ($path in 'D:\PA114\client.jar','D:\PA113\client.jar','D:\PA116\client.jar','D:\native\forge-1.16.5-36.2.34.jar','D:\native\forge-1.14.4-28.2.26.jar','D:\native\client-extra-1.13.2.jar') { +foreach ($path in 'D:\PA114\client.jar','D:\PA113\client.jar','D:\PA116\client.jar','D:\PA1211\client.jar','D:\native\forge-1.16.5-36.2.34.jar','D:\native\forge-1.14.4-28.2.26.jar','D:\native\client-extra-1.13.2.jar','D:\native\forge-1.21.1-52.1.0.jar','D:\native\client-extra-1.20.6.jar') { Assert-Throws { Assert-NativeArchive $path } } if ((Quote-Argument 'D:\native path\') -ne '"D:\native path\\"') { throw 'Trailing-backslash command-line quoting changed.' } @@ -135,10 +147,23 @@ if ($StaticOnly) { return } $project = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path -$power = Join-Path $project 'build/libs/basePower-2.4.14.121011.jar' +$power = Join-Path $project 'build/libs/basePower-2.4.15.121111.jar' Assert-NativeMetadata (Read-JarMetadata $power) 'basepower' $checks++ [xml]$launch = Get-Content -LiteralPath (Join-Path $project 'runClientAllAdvantage.launch') -Raw +$actualAttributes = @{} +foreach ($entry in $launch.launchConfiguration.stringAttribute) { $actualAttributes[$entry.key] = $entry.value } +$actualEnvironment = @{} +foreach ($map in $launch.launchConfiguration.mapAttribute) { + if ($map.key -eq 'org.eclipse.debug.core.environmentVariables') { + foreach ($entry in $map.mapEntry) { $actualEnvironment[$entry.key] = $entry.value } + } +} +if ($actualAttributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -match '\{(?:asset_index|assets_root)\}') { + throw 'Generated combined Eclipse launch still contains unresolved native asset placeholders; concrete asset index/directory are required for manual handoff.' +} +Assert-NativeLaunch $actualEnvironment $actualAttributes +$checks++ $found = @{} foreach ($entry in ($launch.launchConfiguration.listAttribute | Where-Object { $_.key -eq 'org.eclipse.jdt.launching.CLASSPATH' }).listEntry) { [xml]$classpath = $entry.value diff --git a/test-rig/client-resources/test-native-geometry-contract.ps1 b/test-rig/client-resources/test-native-geometry-contract.ps1 new file mode 100644 index 00000000..8bb01f52 --- /dev/null +++ b/test-rig/client-resources/test-native-geometry-contract.ps1 @@ -0,0 +1,76 @@ +param( + [string]$RepositoryRoot = (Join-Path $PSScriptRoot '../..'), + [string]$TargetRoot = $PSScriptRoot, + [string]$NativeSourcesJar +) +$ErrorActionPreference = 'Stop' +$RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path +$TargetRoot = (Resolve-Path -LiteralPath $TargetRoot).Path +$checks = 0 +function Assert-NativeGeometry([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } + $script:checks++ +} +$relative = 'java/zone/moddev/mc/advantageworks/client' +$geometry = Get-Content -LiteralPath (Join-Path $TargetRoot "$relative/ChildRendererGeometryProbe.java") -Raw +$main = Get-Content -LiteralPath (Join-Path $TargetRoot "$relative/ClientResourceProbe.java") -Raw +$projection = Get-Content -LiteralPath (Join-Path $TargetRoot "$relative/NativeGuiProjection.java") -Raw +$runner = Get-Content -LiteralPath (Join-Path $TargetRoot 'run.ps1') -Raw +& (Join-Path $PSScriptRoot 'test-native-probe-contract.ps1') -RepositoryRoot $RepositoryRoot -TargetRoot $TargetRoot +Assert-NativeGeometry ($geometry -notmatch '(?m)^import zone\.moddev\.mc\.(?:basesteam|baseelectric)\.') 'Geometry must not compile against expansion substitutes.' +Assert-NativeGeometry ($geometry -notmatch 'new (?:DrillBitRenderer|LaserDrillRenderer|LaserTurretRenderer|HydroturbineRenderer)|renderGeometry\(|Unsafe|allocateInstance|new Client(?:World|Level)|loadWorld\(') 'Use dispatcher-owned native production renderers without constructor/world bypasses.' +foreach ($fragment in 'minecraft.getBlockEntityRenderDispatcher().getRenderer(tile)', 'minecraft.getEntityRenderDispatcher().renderers.get(type)', + 'renderer.createRenderState()', 'renderer.extractRenderState(tile, state, partial', 'renderer.submit(state, matrices, collector', + 'geometry.render(submitted.last(), buffers.getBuffer(pipeline))', 'new BufferBuilder(allocation, type.mode(), type.format())', + 'try (var built = buffer.buildOrThrow())', 'int count = built.drawState().vertexCount();', 'ByteBuffer data = built.vertexBuffer();', + 'int base = i * format.getVertexSize();', 'data.getFloat(base + p)', 'data.getFloat(base + uv)', 'data.get(base + n) / 127F', + '(data.get(base + c + 3) & 255) / 255F', 'vertices.add(vertex);', 'valueOfType(entry.getKey(), net.minecraft.client.renderer.rendertype.RenderSetup.class)', + 'texturesField.get(state)).get("Sampler0")', 'expectedTexture.equals(selectedTexture)', 'ImageIO.read(resource)', + 'private static final float EPSILON = 0.0001F;', 'mesh(vertices, 24)', 'mesh(vertices, 16)', 'mesh(vertices, 40)', + 'new float[]{0F, 0.5F}', 'for (Direction facing : Direction.values())', 'partial-tick rotation did not move vertices', + 'position.equals(original.pose()) && normal.equals(original.normal())', 'matrices.isEmpty() && matrices.last() == original', + 'native_buffers', 'vertices_xyz_uv_normal_alpha', 'bounds_xyz', 'uv_bounds', 'Float.isFinite(value)', 'blank/degenerate quad', + 'Beam cardinal bounds/length changed', 'Idle turret uses the active optic UVs', 'Active turret uses the idle optic UVs', + 'renderFailure.addSuppressed(failure)', 'child-renderer-geometry.json', 'MANUAL_GAP_WORLD_REQUIRED', 'PASS_WITH_MANUAL_GAPS', + 'cases.size() == 26', 'passed == 25 && gaps == 1', 'type::isValid', 'type.create(net.minecraft.core.BlockPos.ZERO, state)') { + Assert-NativeGeometry ($geometry.Contains($fragment)) "Missing native geometry contract: $fragment" +} +foreach ($fragment in 'new GuiRenderState()', 'new GuiRenderer(', 'new TextureTarget(', 'graphics.renderDeferredElements()', + 'renderer.render(fog.getBuffer(FogRenderer.FogMode.NONE))', 'Screenshot.takeScreenshot(target, captured::set)', + 'fence.awaitCompletion(10_000_000_000L)', 'RenderSystem.executePendingTasks()', 'image.getPixel(x, y)', + 'targetField.set(minecraft, original)', 'widthField.setInt(minecraft.getWindow(), width)', + 'heightField.setInt(minecraft.getWindow(), height)', 'minecraft.getWindow().setGuiScale(scale)', + 'RenderSystem.setProjectionMatrix(projection, projectionType)', 'target.destroyBuffers()') { + Assert-NativeGeometry ($projection.Contains($fragment)) "Missing real native GUI extraction/readback/restoration: $fragment" +} +Assert-NativeGeometry ($projection -notmatch 'readPixels|glReadPixels|matrixMode|loadIdentity|\.ortho\(') 'Native GUI proof must not retain removed immediate-mode GL.' +Assert-NativeGeometry ($main.IndexOf('geometry = inspect("child_geometry"') -gt $main.IndexOf('primary_resource_status')) 'Bounded child geometry must follow all primary scopes.' +Assert-NativeGeometry ($main.Contains('require(primary && geometry,')) 'All failed native scopes must keep the complete probe red.' +Assert-NativeGeometry ($runner.Contains('if ($ChildRendererGeometry -and -not $RequireExtensions)')) 'Geometry must require the combined scope.' +if ($NativeSourcesJar) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $NativeSourcesJar).Path) + $sources = @{} + try { + foreach ($pin in @{ + 'net/minecraft/client/gui/render/GuiRenderer.java'='8242E8AA34C6EEDDE5E1D8BC279D929DABA29E0EFFE3CC9E92AECA0FB94E1519' + 'net/minecraft/client/renderer/CachedOrthoProjectionMatrixBuffer.java'='E74DC690F46A7D62263734A5F885980499C43927E2A88D34344238601AEBA724' + 'net/minecraft/client/Screenshot.java'='3C14AF62FF2854E4F3E0832C51ED88846D98A9C0E319D425F9469644CFA4EC12' + }.GetEnumerator()) { + $entry = $archive.GetEntry($pin.Key) + Assert-NativeGeometry ($null -ne $entry) "Native source entry missing: $($pin.Key)" + $reader = [IO.StreamReader]::new($entry.Open()) + try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() } + $sha = [Security.Cryptography.SHA256]::Create() + try { $hash = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($text))).Replace('-', '') } + finally { $sha.Dispose() } + Assert-NativeGeometry ($hash -ceq $pin.Value) "Mapped Forge 61 source changed: $($pin.Key)" + $sources[$pin.Key] = $text + } + } finally { $archive.Dispose() } + Assert-NativeGeometry ($sources['net/minecraft/client/gui/render/GuiRenderer.java'].Contains('window.getWidth() / window.getGuiScale()')) 'Native GUI projection must use the scoped framebuffer width and GUI scale.' + Assert-NativeGeometry ($sources['net/minecraft/client/gui/render/GuiRenderer.java'].Contains('window.getHeight() / window.getGuiScale()')) 'Native GUI projection must use the scoped framebuffer height and GUI scale.' + Assert-NativeGeometry ($sources['net/minecraft/client/renderer/CachedOrthoProjectionMatrixBuffer.java'].Contains('this.invertY ? p_409651_ : 0.0F, this.invertY ? 0.0F : p_409651_')) 'Native GUI projection must retain the downward Y axis.' + Assert-NativeGeometry ($sources['net/minecraft/client/Screenshot.java'].Contains('.copyTextureToBuffer(')) 'Native screenshot proof must read the actual GPU target.' +} else { Write-Output 'NOT RUN: native engine-source audit; supply the actual Forge 61 mapped source archive.' } +Write-Output "PASS: $checks native geometry, extraction/readback/restoration and optional engine-source checks. This static audit is not a graphical acceptance receipt." diff --git a/test-rig/client-resources/test-native-probe-contract.ps1 b/test-rig/client-resources/test-native-probe-contract.ps1 new file mode 100644 index 00000000..7c27cafa --- /dev/null +++ b/test-rig/client-resources/test-native-probe-contract.ps1 @@ -0,0 +1,54 @@ +param( + [string]$RepositoryRoot = (Join-Path $PSScriptRoot '../..'), + [string]$TargetRoot = $PSScriptRoot, + [string]$JavaHome = 'C:/Users/johnb/.jdks/temurin-21.0.7+6' +) +$ErrorActionPreference = 'Stop' +$RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path +$TargetRoot = (Resolve-Path -LiteralPath $TargetRoot).Path +function Assert-NativeContract([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } +} +function Canonical-NativeContract([string]$Source) { + [regex]::Replace($Source, '"(?:\\.|[^"\\])*"|''(?:\\.|[^''\\])*''|/\*[\s\S]*?\*/|//[^\r\n]*|\s+', { + param($match) + if ($match.Value.StartsWith('"') -or $match.Value.StartsWith("'")) { $match.Value } else { '' } + }) +} +$java = Join-Path $JavaHome 'bin/java.exe' +$source = Join-Path $PSScriptRoot 'contracts/NativeProbeContract.java' +$rules = Join-Path $PSScriptRoot 'contracts/native61-adapters.properties' +$output = & $java $source $RepositoryRoot $TargetRoot $rules verify 2>&1 +$code = $LASTEXITCODE +if ($code -ne 0) { + $errorLine = @($output | ForEach-Object { "$_" } | Where-Object { $_ -match '^Exception in thread "main" java\.lang\.IllegalStateException: ' }) | Select-Object -First 1 + if ($errorLine) { throw ($errorLine -replace '^Exception in thread "main" java\.lang\.IllegalStateException: ', '') } + throw "Native source parser failed: $($output -join [Environment]::NewLine)" +} +$output | ForEach-Object { "$_" } | Where-Object { $_ -notmatch '^Picked up JAVA_TOOL_OPTIONS:' } | Write-Output +$pack = Get-Content -LiteralPath (Join-Path $TargetRoot 'resources/pack.mcmeta') -Raw | ConvertFrom-Json +Assert-NativeContract (($pack.pack.min_format -join ',') -ceq '94,1' -and ($pack.pack.max_format -join ',') -ceq '94,1' -and + -not ($pack.pack.PSObject.Properties.Name -contains 'pack_format')) 'Native probe metadata must retain exact root data range 94.1.' +$metadata = Get-Content -LiteralPath (Join-Path $TargetRoot 'resources/META-INF/mods.toml') -Raw +foreach ($pin in 'loaderVersion="[61,62)"','versionRange="[1.21.11]"','versionRange="[61.1.0]"','versionRange="[2.4.15.121111]"','showAsResourcePack=false') { + Assert-NativeContract ($metadata.Contains($pin)) "Missing native probe metadata pin: $pin" +} +$runner = Get-Content -LiteralPath (Join-Path $TargetRoot 'run.ps1') -Raw +$captionGate = @' +$expectedGuiTextChecks = if ($RequireExtensions) { 351 } else { 126 } +if ($MachineGuiLayouts -and ($result.machine_gui_text_spec -ne 2 -or $result.machine_gui_text_status -ne 'PASS' -or + $result.machine_gui_text_checks -ne $expectedGuiTextChecks -or + $result.machine_gui_negative_controls_rejected -ne ($expectedGuiTextChecks + 4))) { + throw 'Required-caption GUI contract v2 did not pass; an older no-label probe is not acceptable.' +} +'@ +Assert-NativeContract ((Canonical-NativeContract $runner).Contains((Canonical-NativeContract $captionGate))) 'Runner must require all 351 caption checks and 355 negative controls (126/130 for Power only).' +foreach ($fragment in "bookPacketType -ne 'ClientboundRecipeBookAddPacket'", "tagsPacketType -eq 'ClientboundUpdateTagsPacket'", + 'Frozen profile SHA-256 is required and must match.', 'DIAGNOSTIC_LOOSE_POWER_CLIENT_PASS_NOT_FROZEN_CANDIDATE_QUALIFICATION', + 'runner-failure.json', 'Native tag packet SHA-256 mismatch.') { + Assert-NativeContract ($runner.Contains($fragment)) "Missing native client runner contract: $fragment" +} +Assert-NativeContract ($runner -notmatch '\b(?:Remove-Item|Clear-Content)\b') 'Runner must not delete old or failed evidence.' +$failure = [regex]::Match($runner, '(?s)\} catch \{\s*# Preserve native partial results.*?\} finally \{').Value +Assert-NativeContract ($failure.Contains('runner-failure.json') -and $failure.Contains('if (-not (Test-Path')) 'Runner must preserve partial native results and write its failure separately.' +Write-Output 'PASS: native metadata, exact caption counters, paired book/tag packet guards, frozen versus loose-output provenance, and retained-failure contracts. No graphical pass is implied.' diff --git a/test-rig/client-resources/test-probe-contract-negative-controls.ps1 b/test-rig/client-resources/test-probe-contract-negative-controls.ps1 index 6ad3d5a8..fc71f9e8 100644 --- a/test-rig/client-resources/test-probe-contract-negative-controls.ps1 +++ b/test-rig/client-resources/test-probe-contract-negative-controls.ps1 @@ -9,15 +9,15 @@ $relative = 'java/zone/moddev/mc/advantageworks/client' $controls = @( @{ name='recipe-pixel-threshold'; file='RecipeBookGroupingProbe'; before='colored > 1000'; after='colored > 100' }, @{ name='recipe-cell-count'; file='RecipeBookGroupingProbe'; before='shown.size() == 13'; after='shown.size() >= 1' }, - @{ name='recipe-packet-tail'; file='RecipeBookGroupingProbe'; before='require(!buffer.isReadable(),'; after='require(true,' }, - @{ name='tank-packet-quantity'; file='TankVisualProbe'; before='clientTank.getTank().getFluidAmount() == 10000'; after='clientTank.getTank().getFluidAmount() == 1000' }, - @{ name='tank-simulation-mutation'; file='TankVisualProbe'; before='serverTank.getTank().getFluidAmount() == 0'; after='serverTank.getTank().getFluidAmount() >= 0' }, - @{ name='tank-pixel-coordinate'; file='TankVisualProbe'; before='image.getRGB(88, 39)'; after='image.getRGB(0, 0)' }, - @{ name='tank-blue-threshold'; file='TankVisualProbe'; before='blue > red + 20 && blue > green + 10'; after='blue >= 0' }, + @{ name='recipe-packet-tail'; file='RecipeBookGroupingProbe'; before='require(!buffer.isReadable(), "Trailing recipe packet bytes");'; after='require(true, "Trailing recipe packet bytes");' }, + @{ name='tank-packet-quantity'; file='TankVisualProbe'; before='client.getTank().getFluidAmount() == amount &&'; after='client.getTank().getFluidAmount() >= 0 &&' }, + @{ name='tank-simulation-mutation'; file='TankVisualProbe'; before='server.getTank().getFluidAmount() == 0'; after='server.getTank().getFluidAmount() >= 0' }, + @{ name='tank-pixel-coordinate'; file='TankVisualProbe'; before='actual.getRGB(x, y) != expectedPixel'; after='actual.getRGB(0, 0) != expectedPixel' }, + @{ name='tank-whole-gauge-zero-differences'; file='TankVisualProbe'; before='row.getAsJsonObject().get("different_pixels").getAsInt() == 0'; after='row.getAsJsonObject().get("different_pixels").getAsInt() >= 0' }, @{ name='bucket-volume'; file='OilRenderProbe'; before='contents.getAmount() != 1000'; after='contents.getAmount() != 100' }, @{ name='bucket-fluid-identity'; file='OilRenderProbe'; before='contents.getFluid() != expected'; after='false' }, - @{ name='fluid-primitive-capture'; file='OilRenderProbe'; before='int vertices = built.getFirst().vertexCount();'; after='int vertices = 4;' }, - @{ name='fluid-tags-restore'; file='OilRenderProbe'; before='fluidRegistry.reset(restore);'; after='fluidRegistry.reset(temporary);' }, + @{ name='fluid-primitive-capture'; file='OilRenderProbe'; before='int vertices = built.drawState().vertexCount();'; after='int vertices = 4;' }, + @{ name='fluid-tags-restore'; file='OilRenderProbe'; before='net.minecraft.core.registries.Registries.FLUID, previous)).apply();'; after='net.minecraft.core.registries.Registries.FLUID, synthetic)).apply();' }, @{ name='missing-model'; file='ClientResourceProbe'; before='require(model != missing,'; after='require(model == missing,' }, @{ name='missing-model-quads'; file='ClientResourceProbe'; before='!requireQuads || !quads.isEmpty()'; after='true' }, @{ name='missing-caption'; file='MachineGuiLayoutProbe'; before='require(labelError == null, labelError);'; after='require(true, labelError);' }, @@ -27,14 +27,17 @@ $controls = @( @{ name='geometry-epsilon'; file='ChildRendererGeometryProbe'; before='EPSILON = 0.0001F'; after='EPSILON = 0.1F' }, @{ name='geometry-degenerate-threshold'; file='ChildRendererGeometryProbe'; before='x * x + y * y + z * z > 0.00000001F'; after='x * x + y * y + z * z >= 0F' }, @{ name='geometry-primitive-capture'; file='ChildRendererGeometryProbe'; before='data.getFloat(base + p)'; after='0F' }, - @{ name='geometry-native-buffer-import'; file='ChildRendererGeometryProbe'; before='import com.mojang.blaze3d.vertex.BufferBuilder;'; after='import fake.BufferBuilder;'; error='ChildRendererGeometryProbe import bindings changed' }, + @{ name='geometry-native-buffer-import'; file='ChildRendererGeometryProbe'; before='import com.mojang.blaze3d.vertex.BufferBuilder;'; after='import fake.BufferBuilder;' }, @{ name='geometry-actual-texture'; file='ChildRendererGeometryProbe'; before='expectedTexture.equals(selectedTexture)'; after='true' }, @{ name='geometry-matrix-balance'; file='ChildRendererGeometryProbe'; before='position.equals(original.pose()) && normal.equals(original.normal())'; after='true' }, @{ name='geometry-manual-gap'; file='ChildRendererGeometryProbe'; before='passed == 25 && gaps == 1'; after='passed >= 0' }, - @{ name='projection-y-order'; file='NativeGuiProjection'; before='orthographic(0, width, 0, height, near, far)'; after='orthographic(0, width, height, 0, near, far)'; error='Native projection adapter changed' }, - @{ name='projection-restoration'; file='NativeGuiProjection'; before='RenderSystem.setProjectionMatrix(projection);'; after=''; error='Native projection adapter changed' }, + @{ name='projection-framebuffer-axis'; file='NativeGuiProjection'; before='widthField.setInt(window, width);'; after='widthField.setInt(window, height);' }, + @{ name='projection-restoration'; file='NativeGuiProjection'; before='RenderSystem.setProjectionMatrix(projection, projectionType);'; after='' }, @{ name='runner-caption-count'; path='run.ps1'; before='{ 351 } else { 126 }'; after='{ 350 } else { 126 }'; error='Runner must require all 351' }, - @{ name='runner-negative-count'; path='run.ps1'; before='($expectedGuiTextChecks + 4)'; after='($expectedGuiTextChecks + 3)'; error='Runner must require all 351' } + @{ name='runner-negative-count'; path='run.ps1'; before='($expectedGuiTextChecks + 4)'; after='($expectedGuiTextChecks + 3)'; error='Runner must require all 351' }, + @{ name='native-tag-packet-tail'; file='RecipeBookGroupingProbe'; before='require(!buffer.isReadable(), "Trailing native tag packet bytes");'; after='require(true, "Trailing native tag packet bytes");' }, + @{ name='native-tag-packet-required'; file='RecipeBookGroupingProbe'; before='payload != null && !payload.isEmpty()'; after='true' }, + @{ name='native-tag-exact-members'; file='RecipeBookGroupingProbe'; before='.equals(expected.get(key.location()))'; after='.containsAll(expected.get(key.location()))' } ) $results = @() $inputs = @{} @@ -61,7 +64,11 @@ try { $rejected = $false $failure = $null try { & (Join-Path $PSScriptRoot 'test-probe-contract.ps1') -RepositoryRoot $project -TargetRoot $fixture | Out-Null } - catch { $failure = $_.Exception.Message; $rejected = $failure.StartsWith($expected, [StringComparison]::Ordinal) } + catch { + $failure = $_.Exception.Message + $rejected = $failure.StartsWith($expected, [StringComparison]::Ordinal) -or + (-not $control.error -and $failure.StartsWith("Changed inherited oracle method: $($control.file).", [StringComparison]::Ordinal)) + } $results += [ordered]@{ control=$control.name; expected='REJECT_FOR_INTENDED_REASON'; rejected=$rejected; error=$failure } if (-not $rejected) { throw "Weakened oracle was accepted or rejected for an unrelated error: $($control.name). Actual: $failure. Evidence: $output" } } @@ -73,4 +80,5 @@ try { planned=$controls.Count; completed=$results.Count; inputs=$inputs; results=$results } | ConvertTo-Json -Depth 7 | Set-Content -LiteralPath (Join-Path $output 'results.json') -Encoding utf8 } -Write-Output "PASS: $($controls.Count) weakened-oracle controls rejected for their intended reasons; unmodified fixture passed. No Java/client was run; source and worlds unchanged. Evidence: $output" +Write-Output "PASS: $($controls.Count) weakened-oracle controls rejected for their intended reasons; unmodified fixture passed. JVM source parsing only; no Minecraft client/world was run. Source and worlds unchanged. Evidence: $output" +$global:LASTEXITCODE = 0 diff --git a/test-rig/client-resources/test-probe-contract.ps1 b/test-rig/client-resources/test-probe-contract.ps1 index cbdbf362..4c5f982d 100644 --- a/test-rig/client-resources/test-probe-contract.ps1 +++ b/test-rig/client-resources/test-probe-contract.ps1 @@ -5,6 +5,11 @@ param( $ErrorActionPreference = 'Stop' $RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path $TargetRoot = (Resolve-Path -LiteralPath $TargetRoot).Path +$targetMetadata = Get-Content -LiteralPath (Join-Path $TargetRoot 'resources/META-INF/mods.toml') -Raw +if ($targetMetadata.Contains('loaderVersion="[61,62)"')) { + & (Join-Path $PSScriptRoot 'test-native-probe-contract.ps1') -RepositoryRoot $RepositoryRoot -TargetRoot $TargetRoot + return +} $baseline = 'c310d0ca63ad9a8408dd4bba75172afafa5c71b7' $relative = 'java/zone/moddev/mc/advantageworks/client' $referenceHashes = [ordered]@{ diff --git a/test-rig/client-resources/test/zone/moddev/mc/advantageworks/client/NativeRecipeBookGroupingTest.java b/test-rig/client-resources/test/zone/moddev/mc/advantageworks/client/NativeRecipeBookGroupingTest.java new file mode 100644 index 00000000..70e0bbe9 --- /dev/null +++ b/test-rig/client-resources/test/zone/moddev/mc/advantageworks/client/NativeRecipeBookGroupingTest.java @@ -0,0 +1,189 @@ +package zone.moddev.mc.advantageworks.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.netty.buffer.Unpooled; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import net.minecraft.SharedConstants; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.tags.TagKey; +import net.minecraft.tags.TagLoader; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.network.protocol.game.ClientboundRecipeBookAddPacket; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.context.ContextMap; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.RecipeBookCategories; +import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; +import net.minecraft.world.item.crafting.display.RecipeDisplayId; +import net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Real native packet bytes and ClientRecipeBook collections; no graphics-pass claim. */ +public class NativeRecipeBookGroupingTest { + private static RegistryAccess registries; + private static ContextMap context; + + @BeforeClass public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + context = RecipeBookGroupingProbe.context(registries); + } + + @Test public void actualClientBuilderKeepsVariantsTogetherAndOutputsSeparate() { + var verified = RecipeBookGroupingProbe.verifyPacket(registries, context, bytes(entries(false, false)), manifest()); + assertEquals(3, verified.recipes()); + assertEquals(3, verified.displays()); + assertEquals(2, verified.book().getCollections().size()); + assertEquals(List.of(1, 2), verified.book().getCollections().stream() + .map(cell -> cell.getRecipes().size()).sorted().toList()); + } + + @Test public void splitVariantGroupIsRejected() { + rejected(bytes(entries(true, false)), manifest(), "Missing/split native cell"); + } + + @Test public void mixedOutputNumericGroupIsRejected() { + rejected(bytes(entries(false, true)), manifest(), "Native numeric group mixes output items"); + } + + @Test public void oldPacketTypeIsRejected() { + JsonObject manifest = manifest(); + manifest.addProperty("bookPacketType", "ClientboundUpdateRecipesPacket"); + rejected(bytes(entries(false, false)), manifest, "native display-packet protocol"); + } + + @Test public void duplicateManifestDisplayIdIsRejected() { + JsonObject manifest = manifest(); + manifest.getAsJsonArray("bookRecipes").get(1).getAsJsonObject().getAsJsonArray("displayIds").set(0, + new com.google.gson.JsonPrimitive(1)); + rejected(bytes(entries(false, false)), manifest, "Duplicate/invalid display ID"); + } + + @Test public void missingVariantIsRejected() { + rejected(bytes(entries(false, false).subList(0, 2)), manifest(), "Missing network displays"); + } + + @Test public void alteredOutputCountIsRejected() { + JsonObject manifest = manifest(); + manifest.getAsJsonArray("bookRecipes").get(0).getAsJsonObject().addProperty("count", 2); + rejected(bytes(entries(false, false)), manifest, "count mismatch"); + } + + @Test public void trailingNetworkBytesAreRejected() { + byte[] original = bytes(entries(false, false)); + rejected(Arrays.copyOf(original, original.length + 1), manifest(), "Trailing recipe packet bytes"); + } + + @Test public void namedIngredientRequiresActualNativeTagPacketBeforeRecipeDecode() { + byte[] tags = tagPacket(); + assertEquals(1, RecipeBookGroupingProbe.applyItemTags(registries, tags)); + var key = TagKey.create(Registries.ITEM, Identifier.parse("advantageclientresources:native_fixture")); + var display = entry(1, 3, new ItemStack(Items.APPLE)).contents(); + var named = new RecipeDisplayEntry(display.id(), display.display(), display.group(), display.category(), + Optional.of(List.of(Ingredient.of(BuiltInRegistries.ITEM.get(key).orElseThrow())))); + var entries = new java.util.ArrayList<>(entries(false, false)); + entries.set(0, new ClientboundRecipeBookAddPacket.Entry(named, false, false)); + byte[] book = bytes(entries); + BuiltInRegistries.ITEM.prepareTagReload(new TagLoader.LoadResult<>(Registries.ITEM, java.util.Map.of())).apply(); + boolean missingTagRejected = false; + try { RecipeBookGroupingProbe.verifyPacket(registries, context, book, manifest()); } + catch (java.util.NoSuchElementException expected) { missingTagRejected = true; } + assertTrue("Native recipe decoder accepted an absent named tag", missingTagRejected); + assertEquals(1, RecipeBookGroupingProbe.applyItemTags(registries, tags)); + assertEquals(3, RecipeBookGroupingProbe.verifyPacket(registries, RecipeBookGroupingProbe.context(registries), book, manifest()).displays()); + } + + @Test public void trailingNativeTagPacketBytesAreRejected() { + byte[] tags = tagPacket(); + try { + RecipeBookGroupingProbe.applyItemTags(registries, Arrays.copyOf(tags, tags.length + 1)); + fail("Native tag packet decoder accepted trailing bytes"); + } catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("Trailing native tag packet bytes")); } + } + + @Test public void nativeTagPacketCannotSilentlyDropUnknownRegistryMembers() { + try { + RecipeBookGroupingProbe.applyItemTags(registries, tagPacket(BuiltInRegistries.ITEM.size() + 100)); + fail("Native item-tag registry mismatch was silently dropped"); + } catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("dropped/changed a member")); } + } + + private static byte[] tagPacket() { + return tagPacket(BuiltInRegistries.ITEM.getId(Items.STICK)); + } + + private static byte[] tagPacket(int itemId) { + FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer()); + try { + buffer.writeVarInt(1); + buffer.writeResourceKey(Registries.ITEM); + buffer.writeVarInt(1); + buffer.writeIdentifier(Identifier.parse("advantageclientresources:native_fixture")); + buffer.writeIntIdList(new it.unimi.dsi.fastutil.ints.IntArrayList(new int[]{itemId})); + return io.netty.buffer.ByteBufUtil.getBytes(buffer); + } finally { buffer.release(); } + } + + private static List entries(boolean split, boolean mixed) { + return List.of(entry(1, 3, new ItemStack(Items.APPLE)), + entry(2, split ? 6 : 3, new ItemStack(Items.APPLE, 2)), + entry(3, mixed ? 3 : 7, new ItemStack(Items.CARROT))); + } + + private static ClientboundRecipeBookAddPacket.Entry entry(int id, int group, ItemStack output) { + var display = new ShapedCraftingRecipeDisplay(1, 1, List.of(new SlotDisplay.ItemSlotDisplay(Items.STICK)), + new SlotDisplay.ItemStackSlotDisplay(output), new SlotDisplay.ItemSlotDisplay(Items.CRAFTING_TABLE)); + return new ClientboundRecipeBookAddPacket.Entry(new RecipeDisplayEntry(new RecipeDisplayId(id), display, + OptionalInt.of(group), RecipeBookCategories.CRAFTING_MISC, Optional.empty()), false, false); + } + + private static JsonObject manifest() { + JsonObject result = new JsonObject(); + result.addProperty("status", "PASS"); + result.addProperty("bookPacketType", "ClientboundRecipeBookAddPacket"); + JsonArray rows = new JsonArray(); + result.add("bookRecipes", rows); + for (int i = 1; i <= 3; i++) { + JsonObject row = new JsonObject(); + row.addProperty("id", "basepower:fixture_" + i); + row.addProperty("output", i == 3 ? "minecraft:carrot" : "minecraft:apple"); + row.addProperty("group", row.get("output").getAsString()); + row.addProperty("type", "minecraft:crafting"); + row.addProperty("count", i == 2 ? 2 : 1); + JsonArray displays = new JsonArray(); displays.add(i); row.add("displayIds", displays); + rows.add(row); + } + return result; + } + + private static byte[] bytes(List entries) { + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), registries); + try { + ClientboundRecipeBookAddPacket.STREAM_CODEC.encode(buffer, new ClientboundRecipeBookAddPacket(entries, true)); + return io.netty.buffer.ByteBufUtil.getBytes(buffer); + } finally { buffer.release(); } + } + + private static void rejected(byte[] bytes, JsonObject manifest, String reason) { + try { + RecipeBookGroupingProbe.verifyPacket(registries, context, bytes, manifest); + fail("Native client oracle accepted mutation: " + reason); + } catch (IllegalStateException expected) { + assertTrue(expected.toString(), expected.getMessage().contains(reason)); + } + } +} diff --git a/test-rig/gradle.properties b/test-rig/gradle.properties index 1920ad42..6ebada88 100644 --- a/test-rig/gradle.properties +++ b/test-rig/gradle.properties @@ -1,10 +1,10 @@ org.gradle.jvmargs=-Xmx2G org.gradle.daemon=false -minecraft_version=1.21.1 -forge_version=52.1.0 +minecraft_version=1.21.11 +forge_version=61.1.0 mapping_channel=official -mapping_version=1.21.1 -mcp_version=20240808.132146 -harness_version=1.1.5.121011 -works_power_sha256=F91A613EB5FAA218AAD2DC3C881DEC7B897A5EE874215282A439E34B915C1AE6 +mapping_version=1.21.11 +mcp_version=20251223.124241 +harness_version=1.1.5.121111 +works_power_sha256=C98D2CF163BE2480D9AFB0023BBD2D92D401AE0D50BEF73191235BF3F83774A7 diff --git a/test-rig/migration/audit_native12111_endurance.py b/test-rig/migration/audit_native12111_endurance.py new file mode 100644 index 00000000..fb73d1de --- /dev/null +++ b/test-rig/migration/audit_native12111_endurance.py @@ -0,0 +1,34 @@ +"""Independently replay each saved checkpoint from the full Forge 61 endurance run.""" + +import endurance +import native12111 +from audit_native1201_endurance import main +from native117_broad import read_json +from native12111_endurance import measure_batch + + +class Forge61: + MINECRAFT, FORGE = native12111.MINECRAFT, native12111.FORGE + verify_runtime = staticmethod(native12111.audit_staged) + parse_snapshot = staticmethod(native12111.parse_endurance_snapshot) + + @staticmethod + def require_native_boot(path): + native12111.require_clean_log(path) + text = path.read_text(encoding="utf-8") + for marker in (f"Starting minecraft server version {native12111.MINECRAFT}", + f"Forge mod loading, version {native12111.FORGE}, for MC {native12111.MINECRAFT} " + f"with MCP {native12111.MCP}", "Done ("): + endurance.require(marker in text, f"Missing native startup witness in {path.name}: {marker}") + + @staticmethod + def verify_batch(runtime, label, documents, definitions): + stations = ("S-04", "E-03", "W-04", "W-03") + measured = measure_batch({station: documents[station] for station in stations}, definitions) + recorded = read_json(runtime / f"semantic-batch-{label}.json") + endurance.require(measured["status"] == "PASS" and measured == recorded, + f"{runtime.name}/{label}: replay disagrees with saved item and fluid observations") + + +if __name__ == "__main__": + main(adapter=Forge61) diff --git a/test-rig/migration/matrix.py b/test-rig/migration/matrix.py index 975ece31..37e9031d 100644 --- a/test-rig/migration/matrix.py +++ b/test-rig/migration/matrix.py @@ -71,7 +71,7 @@ def wait(self, token, timeout=60): line = self.lines.get(timeout=0.5) if token in line: return - if any(failure in line for failure in ("Migration probe failed", "Actor probe failed", "Recipe probe failed", "Connected probe failed", "ADVRECIPES FAIL", "ADVPROGRESSION FAIL")): + if any(failure in line for failure in ("Migration probe failed", "Actor probe failed", "Recipe probe failed", "Connected probe failed", "ADVRECIPES FAIL", "ADVPROGRESSION FAIL")) or getattr(self, "unexpected_line", lambda value: False)(line): raise RuntimeError(line.strip()) except queue.Empty: if self.process.poll() is not None: diff --git a/test-rig/migration/native117_code.py b/test-rig/migration/native117_code.py index 30c5cace..b1fe190b 100644 --- a/test-rig/migration/native117_code.py +++ b/test-rig/migration/native117_code.py @@ -54,6 +54,13 @@ def close(self): class NativeLaunch(HistoricalServer): forge_version = "1.17.1-37.1.1" + def launch_environment(self): + """None preserves legacy inheritance; native engines may return a pinned snapshot.""" + return None + + def logging_configuration(self, logging_jar): + return "jar:" + logging_jar.resolve().as_uri() + "!/log4j2.xml" + def validate_native_log(self, path): from native117 import require_clean_log require_clean_log(path) @@ -62,16 +69,17 @@ def __init__(self, runtime, java, launcher, label): runtime = Path(runtime) self.native_label = label self.native_runtime = runtime + environment = self.launch_environment() logging_jar = runtime / f"libraries/net/minecraftforge/fmlloader/{self.forge_version}/fmlloader-{self.forge_version}.jar" if not logging_jar.is_file(): raise FileNotFoundError("Missing pinned Forge logging configuration") - logging_configuration = "jar:" + logging_jar.resolve().as_uri() + "!/log4j2.xml" + logging_configuration = self.logging_configuration(logging_jar) self.lines = queue.Queue() self.log = (runtime / f"console-{label}.log").open("x", encoding="utf-8") self.process = subprocess.Popen( [str(java), "-Xms512M", "-Xmx2G", "--add-opens", "java.base/java.lang.invoke=ALL-UNNAMED", "-Dlog4j.configurationFile=" + logging_configuration, "-Dfml.queryResult=confirm", "@" + str(launcher), "nogui"], - cwd=runtime, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + cwd=runtime, env=environment, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) self.thread = threading.Thread(target=self.read, daemon=True) diff --git a/test-rig/migration/native117_recipes.py b/test-rig/migration/native117_recipes.py index 4902ed53..f21f2cfe 100644 --- a/test-rig/migration/native117_recipes.py +++ b/test-rig/migration/native117_recipes.py @@ -189,10 +189,11 @@ def audit_unlock_log(path, previous_recipes, current_recipes, errors=runtime_err events = [] for original in errors(path.read_text(encoding="utf-8", errors="replace")): line = re.sub(r"\x1b\[[0-9;]*m", "", original) - match = re.search(r"\[Server thread/ERROR\] \[(?:minecraft/ServerRecipeBook|net\.minecraft\.stats\.ServerRecipeBook/?)\]: Tried to load unrecognized recipe: ([a-z0-9_]+:[a-z0-9_./-]+) removed now\.$", line) - if not match or match[1] not in removed: + match = re.search(r"\[Server thread/ERROR\] \[(?:minecraft/ServerRecipeBook|net\.minecraft\.stats\.ServerRecipeBook/?)\]: Tried to load unrecognized recipe: (?:([a-z0-9_]+:[a-z0-9_./-]+)|ResourceKey\[minecraft:recipe / ([a-z0-9_]+:[a-z0-9_./-]+)\]) removed now\.$", line) + identity = (match[1] or match[2]) if match else None + if identity not in removed: raise AssertionError(f"Unexpected native recipe runtime error: {original}") - observed.add(match[1]) + observed.add(identity) events.append(original) if observed != removed: raise AssertionError(f"Saved-book pruning did not match the native packet-set difference: expected {sorted(removed)}, saw {sorted(observed)}") @@ -281,13 +282,17 @@ def progression_campaign(root, target, entry, java, port, report, *, engine=None server_type = Server if engine is None else engine.server version = ("1.17.1", "37.1.1") if engine is None else engine.version result_name = "native117-recipes-results.json" if engine is None else engine.result_name - pack_format = 7 if engine is None else engine.pack_format + pack_metadata = None if engine is None else getattr(engine, "pack_metadata", None) + if pack_metadata is None: + pack_metadata = {"pack_format": 7 if engine is None else engine.pack_format, + "description": "Disposable loot-override witness"} + require_log = require_clean_log if engine is None else getattr(engine, "require_clean_log", require_clean_log) runtime = root / "progression" launcher = stage_runtime(runtime, target, port, "progression") definition = fixture_ids(progression_fixture()) write_json(runtime / "progression-fixture.json", definition) pack = runtime / WORLD / "datapacks/progression-override" - write_json(pack / "pack.mcmeta", {"pack": {"pack_format": pack_format, "description": "Disposable loot-override witness"}}) + write_json(pack / "pack.mcmeta", {"pack": pack_metadata}) loot_directory = "loot_tables" if engine is None else getattr(engine, "loot_directory", "loot_tables") write_json(pack / f"data/minecraft/{loot_directory}/chests/simple_dungeon.json", { "pools": [{"name": "test_override", "rolls": 1, "entries": [{"type": "item", "name": "minecraft:dirt"}]}]}) @@ -316,7 +321,7 @@ def progression_campaign(root, target, entry, java, port, report, *, engine=None server.send("save-all flush") finally: server.close() - require_clean_log(runtime / f"console-{label}.log") + require_log(runtime / f"console-{label}.log") verify_level(runtime, entry, *version) diff --git a/test-rig/migration/native12111.py b/test-rig/migration/native12111.py new file mode 100644 index 00000000..0726663a --- /dev/null +++ b/test-rig/migration/native12111.py @@ -0,0 +1,562 @@ +"""Run the Forge 61 Power/Base Metals interop witnesses in a fresh disposable world.""" + +import argparse +import base64 +import gzip +import hashlib +import io +import json +import os +from pathlib import Path +import re +import subprocess +import traceback +import xml.etree.ElementTree as ET +from zipfile import ZipFile + +import nbtlib + +from matrix import digest, fingerprint, stage as stage_server, write_json +from native117 import jar_identity, output_path, require_disjoint +from native117_code import NativeLaunch, code_files +from native117_interop import mark_runtime, require_checks, require_probe_order +from native117_readiness import await_ready +from native1201 import FLAT_SETTINGS, log_findings as previous_log_findings + + +RIG = Path(__file__).resolve().parents[1] +MINECRAFT = "1.21.11" +FORGE = "61.1.0" +MCP = "20251223.124241" +FORGE_VERSION = f"{MINECRAFT}-{FORGE}" +SHIM = f"forge-{FORGE_VERSION}-shim.jar" +SHIM_SHA = "09008DB9881E35E62669865F3B7420741EDB1A52BC59C67A5FA03D24C6C8E6E3" +LAUNCHER = f"libraries/net/minecraftforge/forge/{FORGE_VERSION}/" + ( + "win_args.txt" if os.name == "nt" else "unix_args.txt") +INVENTORY = "native12111-code-inventory.json" +EXPLORATORY = "EXPLORATORY_UNQUALIFIED" +BASE_PROFILE = RIG / "profiles/native12111/power-interop.json" + + +def log_findings(text): + """Classify only exact DEBUG capability probes reproduced by unmodded Forge.""" + header = re.compile(r"^\[[^\]]+\] \[[^\]]+/([A-Z]+)\] \[([^\]]+)/\]: (.*)$") + blocks = [] + for line in text.splitlines(): + if header.fullmatch(line) or not blocks: + blocks.append([line]) + else: + blocks[-1].append(line) + failures, diagnostics = [], [] + probes = { + ("io.netty.channel.kqueue.KQueue", "KQueue support is not available"): + "Caused by: java.lang.IllegalStateException: Only supported on OSX/BSD", + ("io.netty.channel.epoll.Epoll", "Epoll support is not available"): + "Caused by: java.lang.IllegalStateException: Only supported on Linux", + } + unsafe = "java.lang.IllegalAccessException: symbolic reference class is not accessible: class jdk.internal.misc.Unsafe, from class io.netty.util.internal.PlatformDependent0 (module io.netty.common)" + for block in blocks: + if any(re.search(r"Server empty for \d+ seconds, pausing", line) for line in block): + failures.extend(line for line in block if "Server empty for " in line) + continue + match = header.fullmatch(block[0]) + body = [line for line in block[1:] if line.strip()] + reason = probes.get((match[2], match[3])) if match and match[1] == "DEBUG" else None + expected = set() + if reason: + expected = {"java.lang.ExceptionInInitializerError", reason} + elif match and match[1] == "DEBUG" and (match[2], match[3]) == ( + "io.netty.util.internal.PlatformDependent0", "jdk.internal.misc.Unsafe.allocateUninitializedArray(int): unavailable"): + expected = {unsafe} + details = {line for line in body if not re.fullmatch(r"\tat .*|\t\.\.\. [0-9]+ more", line)} + if expected and details == expected and not any("zone.moddev.mc." in line for line in body): + diagnostics.append(dict(probe=match[3], logger=match[2], level="DEBUG", stack=body, + classification="Native Forge/Netty platform capability fallback")) + else: + errors, platform = previous_log_findings("\n".join(block)) + failures.extend(errors) + diagnostics.extend(platform) + return failures, diagnostics + + +def require_clean_log(path): + failures, diagnostics = log_findings(path.read_text(encoding="utf-8", errors="replace")) + if failures: + raise AssertionError(f"Native runtime errors in {path}: {failures[:5]}") + return diagnostics + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def compat_tile_nbt(row): + if "nbtBase64" in row: + data = nbtlib.File.parse(io.BytesIO(gzip.decompress(base64.b64decode(row["nbtBase64"], validate=True)))) + else: + data = nbtlib.parse_nbt(row.get("nbt", "{}")) + # Project component data for existing inventory oracles without discarding + # the original component maps that their full-stack comparisons also use. + for stack in data.get("Items", []): + count = stack.get("count") + if not isinstance(count, (nbtlib.Byte, nbtlib.Short, nbtlib.Int, nbtlib.Long)) or "Count" in stack or "tag" in stack: + raise AssertionError(f"Expected a native 1.21.11 stack in checkpoint: {stack}") + stack["Count"] = count + custom = stack.get("components", {}).get("minecraft:custom_data") + if custom is not None: + stack["tag"] = custom + return data + + +def compat_tile_data(row): + return compat_tile_nbt(row).unpack() + + +def parse_endurance_snapshot(document): + return {tuple(row["pos"]): {**row, "data": compat_tile_data(row)} for row in document["snapshot"]} + + +def audit_staged(runtime): + expected = {entry["file"]: entry["sha256"] for entry in read_json(runtime / "input-jars.json")} + verify_runtime(runtime, read_json(runtime / INVENTORY), expected) + + +def validate_hash(path, expected): + if not re.fullmatch(r"[A-Fa-f0-9]{64}", expected) or digest(path) != expected.upper(): + raise AssertionError(f"Wrong or missing artifact hash: {path}") + + +def java21(executable): + java = executable.resolve(strict=True) + result = subprocess.run([str(java), "-version"], env=native_launch_environment(), + capture_output=True, text=True, check=True, timeout=30) + version = (result.stderr or result.stdout).strip() + if not re.search(r'version "21(?:\.|")', version): + raise ValueError(f"Forge 61 test requires Java 21: {version}") + return java, version + + +def inspect_seed(seed): + validate_hash(seed / SHIM, SHIM_SHA) + command = (seed / LAUNCHER).read_text(encoding="utf-8").split() + if command != ["-Djava.net.preferIPv6Addresses=system", "-jar", SHIM]: + raise AssertionError("Forge 61 launcher is not the official shim command") + return {"forge": FORGE_VERSION, "files": code_files(seed)} + + +def seal(seed, inventory): + require_disjoint(inventory, seed) + if inventory.exists() or not inventory.is_relative_to(RIG / "build"): + raise ValueError("Inventory must be a new path under test-rig/build") + write_json(inventory, inspect_seed(seed)) + + +def read_mods(args): + data = read_json(args.profile) + if (data.get("mode"), data.get("minecraftVersion"), data.get("forgeVersion"), + data.get("qualificationStatus")) != ("packaged", MINECRAFT, FORGE, EXPLORATORY): + raise ValueError("Use the exploratory Forge 61 packaged profile") + mods = { + "basepower": ("2.4.15.121111", args.power, args.power_sha256), + "advantageworkstest": ("1.1.5.121111", args.harness, args.harness_sha256), + } + for entry in data["mods"]: + identity = entry["id"] + if identity in mods: + raise ValueError(f"Duplicate mod identity: {identity}") + mods[identity] = (entry["version"], (RIG / entry["path"]).resolve(), entry["sha256"]) + if set(mods) != {"basepower", "advantageworkstest", "basemetals", "orespawn"}: + raise ValueError("Power interop requires exactly Power, Base Metals, OreSpawn, and the harness") + for identity, (version, path, sha) in mods.items(): + if not version.endswith(".121111") or not path.name.endswith(f"-{version}.jar"): + raise ValueError(f"Expected an ordinary Forge 61 jar: {path}") + validate_hash(path, sha) + jar_identity(path, identity, version) + verify_compile_pin(args.harness, args.power_sha256) + return mods + + +def verify_compile_pin(harness, power_sha256): + with ZipFile(harness) as jar: + manifest = dict(line.split(": ", 1) for line in + jar.read("META-INF/MANIFEST.MF").decode().replace("\r\n ", "").splitlines() + if ": " in line) + if (manifest.get("Built-On") != FORGE_VERSION + or manifest.get("Power-Input-Status") != EXPLORATORY + or manifest.get("Power-Input-SHA256") != power_sha256.upper()): + raise AssertionError("Harness compile pin and staged ordinary Power jar differ") + + +def read_family_target(profile, seed, inventory_file, inventory_sha256): + validate_hash(inventory_file, inventory_sha256) + inventory = read_json(inventory_file) + if inspect_seed(seed) != inventory: + raise AssertionError("Official Forge server differs from its sealed code inventory") + data = read_json(profile) + if (data.get("mode"), data.get("minecraftVersion"), data.get("forge", {}).get("version"), + data.get("qualificationStatus")) != ("packaged", MINECRAFT, FORGE_VERSION, EXPLORATORY): + raise ValueError("Expected a materialized Forge 61 packaged profile") + mods = {} + for item in data["mods"]: + identity = item["id"] + path = (RIG / item["path"]).resolve(strict=True) + version = item["version"] + if identity in mods or not version.endswith(".121111") or not path.name.endswith(f"-{version}.jar") or path.name != item["file"]: + raise ValueError(f"Invalid ordinary jar pin: {identity}") + validate_hash(path, item["sha256"]) + jar_identity(path, identity, version) + mods[identity] = (version, path, item["sha256"]) + if not {"basepower", "advantageworkstest", "basesteam", "baseelectric", "basemetals", "orespawn"} <= mods.keys(): + raise ValueError("A full family profile must pin Power, Steam, Electric, mandatory BaseMetals, OreSpawn and Advantage Works") + verify_compile_pin(mods["advantageworkstest"][1], mods["basepower"][2]) + return seed, inventory, mods + + +def materialize_profile(args): + runtime_profile = args.output.resolve() + if not runtime_profile.is_relative_to(RIG / "build") or runtime_profile.exists(): + raise ValueError("Write the pinned profile to a new file under test-rig/build") + mods = read_mods(args) + if args.name == "power-interop": + base = {"name": args.name, "levelType": "minecraft:flat", "buildWorks": False, "lifecycleStations": []} + origin = args.profile + required = set(mods) + else: + if not re.fullmatch(r"[a-z0-9-]+", args.name): + raise ValueError("Invalid profile name") + origin = RIG / "profiles/native1201" / (args.name + ".json") + base = read_json(origin) + template_version = base.get("minecraftVersion", base.get("forge", {}).get("version", "").split("-")[0]) + if (base.get("name") != args.name or template_version != "1.20.1" + or base.get("forge", {}).get("version") != "1.20.1-47.4.10"): + raise ValueError("Profile template must be a named native1201 profile") + required = {entry["id"] for entry in base["mods"]} | {"basepower", "orespawn"} + for identity, path, sha in (("basesteam", args.steam, args.steam_sha256), + ("baseelectric", args.electric, args.electric_sha256), + ("mineralogy", args.mineralogy, args.mineralogy_sha256)): + if identity not in required: + continue + if path is None or sha is None: + raise ValueError(f"Need an ordinary 1.21.11 jar and exact SHA-256 for {identity}") + version = path.stem.rsplit("-", 1)[-1] + if not version.endswith(".121111"): + raise ValueError(f"Wrong Minecraft/loader target for {identity}: {version}") + validate_hash(path, sha) + jar_identity(path, identity, version) + mods[identity] = (version, path, sha) + for legacy in ("powerDeobfSha256", "sourceCommit"): + base.pop(legacy, None) + base.update(mode="packaged", runtimeBase="build/works-native12111", minecraftVersion=MINECRAFT, + forge={"version": FORGE_VERSION}, portStatus="PORTED_UNQUALIFIED", + qualificationStatus=EXPLORATORY, sourceVersion=mods["basepower"][0], + sourceProfileSha256=digest(origin), mods=[ + dict(id=key, version=version, path=str(path.resolve()), file=path.name, sha256=sha.upper()) + for key, (version, path, sha) in mods.items() if key in required + ]) + if {entry["id"] for entry in base["mods"]} != required: + raise ValueError("Profile template references unpinned mods") + write_json(runtime_profile, base) + + +def verify_runtime(runtime, inventory, expected): + if inspect_seed(runtime) != inventory: + raise AssertionError("Staged Forge code changed") + if {p.name: digest(p) for p in (runtime / "mods").glob("*.jar")} != expected: + raise AssertionError("Staged mod jars changed") + + +def stage(runtime, seed, inventory, mods, port): + stage_server(runtime, (seed, LAUNCHER, [mod[1] for mod in mods.values()])) + expected = {path.name: sha.upper() for _, path, sha in mods.values()} + write_json(runtime / INVENTORY, inventory) + settings = runtime / "server.properties" + overrides = { + "level-type": "minecraft:flat", "generator-settings": json.dumps(FLAT_SETTINGS, separators=(",", ":")), + "server-port": port, "generate-structures": "false", "simulation-distance": 4, "allow-nether": "true", + "pause-when-empty-seconds": "-1", + } + existing = [line for line in settings.read_text().splitlines() if line.partition("=")[0] not in overrides] + settings.write_text("\n".join(existing + [f"{key}={value}" for key, value in overrides.items()]) + "\n") + config = runtime / "config/fml.toml" + config.parent.mkdir() + config.write_text("versionCheck = false\n", encoding="utf-8") + mark_runtime(runtime) + verify_runtime(runtime, inventory, expected) + return expected + + +def native_launch_environment(runtime=None, label=None, *, diagnostic_environment=None, diagnostic_environment_sha256=None): + """No inherited JVM options; sealed, runtime-bound mixin export is diagnostic only.""" + environment = os.environ.copy() + for variable in ("JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS"): + if environment.get(variable, "").strip(): + raise ValueError(f"Forge 61 cannot inherit JVM options or agents from {variable}") + if diagnostic_environment is None and diagnostic_environment_sha256 is None: + return environment + if diagnostic_environment is None or diagnostic_environment_sha256 is None: + raise ValueError("Diagnostic environment requires an explicit manifest and SHA-256 together") + if not isinstance(diagnostic_environment_sha256, str) or not re.fullmatch(r"[A-Fa-f0-9]{64}", diagnostic_environment_sha256): + raise ValueError("Diagnostic environment requires an exact SHA-256 pin") + if not isinstance(label, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,100}", label) or label in (".", ".."): + raise ValueError("Unsafe diagnostic launch label") + if runtime is None: + raise ValueError("Diagnostic environment requires an explicit runtime") + runtime = Path(runtime).resolve(strict=True) + manifest = Path(diagnostic_environment).resolve(strict=True) + if not runtime.is_dir() or not manifest.is_file() or manifest.stat().st_size > 16384: + raise ValueError("Diagnostic manifest must be a bounded file for an existing runtime") + raw = manifest.read_bytes() + if len(raw) > 16384 or hashlib.sha256(raw).hexdigest().upper() != diagnostic_environment_sha256.upper(): + raise ValueError("Diagnostic environment SHA-256 mismatch") + + def unique_fields(pairs): + document = {} + for key, value in pairs: + if key in document: + raise ValueError(f"Duplicate diagnostic manifest field: {key}") + document[key] = value + return document + + document = json.loads(raw, object_pairs_hook=unique_fields) + if (not isinstance(document, dict) + or set(document) != {"format", "purpose", "runtime", "label", "properties"} + or type(document["format"]) is not int or document["format"] != 1 + or document["purpose"] != "NATIVE61_MIXIN_EXPORT_DIAGNOSTIC" + or document["runtime"] != str(runtime) or document["label"] != label): + raise ValueError("Diagnostic manifest must bind this native61 runtime, label and purpose") + properties = document["properties"] + allowed = {"mixin.debug.export", "mixin.debug.export.decompile", "mixin.debug.export.filter"} + if (not isinstance(properties, dict) or set(properties) - allowed + or properties.get("mixin.debug.export") is not True): + raise ValueError("Only an explicit mixin-export diagnostic may inject JVM properties") + options = ["-Dmixin.debug.export=true"] + if "mixin.debug.export.decompile" in properties: + decompile = properties["mixin.debug.export.decompile"] + if type(decompile) is not bool: + raise ValueError("Mixin export decompile must be a boolean") + options.append("-Dmixin.debug.export.decompile=" + str(decompile).lower()) + if "mixin.debug.export.filter" in properties: + pattern = properties["mixin.debug.export.filter"] + if not isinstance(pattern, str) or not re.fullmatch(r"[A-Za-z0-9_.$*?]{1,256}", pattern): + raise ValueError("Unsafe mixin export filter") + options.append("-Dmixin.debug.export.filter=" + pattern) + injected = {"JAVA_TOOL_OPTIONS": " ".join(options)} + receipt = runtime / f"native61-launch-environment-{label}.json" + with receipt.open("x", encoding="utf-8") as output: + json.dump(dict(status="DIAGNOSTIC_NOT_QUALIFIED", qualificationAllowed=False, + manifestSha256=diagnostic_environment_sha256.upper(), + manifest=document, environment=injected), output, indent=2, sort_keys=True) + output.write("\n") + environment.update(injected) + return environment + + +class Server(NativeLaunch): + forge_version = FORGE_VERSION + + def __init__(self, runtime, java, launcher, label, *, diagnostic_environment=None, diagnostic_environment_sha256=None): + self.diagnostic_environment = diagnostic_environment + self.diagnostic_environment_sha256 = diagnostic_environment_sha256 + super().__init__(runtime, java, launcher, label) + + def launch_environment(self): + return native_launch_environment(self.native_runtime, self.native_label, + diagnostic_environment=self.diagnostic_environment, + diagnostic_environment_sha256=self.diagnostic_environment_sha256) + + @staticmethod + def unexpected_line(line): + return re.search(r"Server empty for \d+ seconds, pausing", line) is not None + + def logging_configuration(self, logging_jar): + with ZipFile(logging_jar) as jar: + original = jar.read("log4j2.xml") + tree = ET.fromstring(original) + changed = 0 + for layout in tree.iter("PatternLayout"): + pattern = layout.get("pattern", "") + if "%xEx" in pattern: + changed += pattern.count("%xEx") + layout.set("pattern", pattern.replace("%xEx", "%ex")) + if changed != 2: + raise AssertionError("Forge 61 logging changed; review the platform formatting adapter") + # Plain stack traces retain every exception and logger threshold without + # initializing unavailable OSX/Linux Netty classes to render version labels. + content = ET.tostring(tree, encoding="utf-8", xml_declaration=True) + config = self.native_runtime / "forge61-log4j2-plain-stacktraces.xml" + if config.exists(): + if config.read_bytes() != content: + raise AssertionError("The native logging configuration changed during the test") + else: + with config.open("xb") as output: + output.write(content) + write_json(self.native_runtime / "forge61-logging-custody.json", dict( + sourceJarSha256=digest(logging_jar), configSha256=digest(config), + modification="Two extended stack-trace renderers replaced by plain stack traces; all levels and filters retained")) + return config.resolve().as_uri() + + def send(self, command): + if not command.startswith("gamerule "): + return super().send(command) + words = command.split() + if len(words) not in (2, 3): + raise ValueError("Expected a single gamerule query or assignment") + aliases = {"doMobSpawning": "spawn_mobs", "randomTickSpeed": "random_tick_speed", + "doWeatherCycle": "advance_weather", "doDaylightCycle": "advance_time", + "doTileDrops": "block_drops", "doEntityDrops": "entity_drops", + "keepInventory": "keep_inventory", "mobGriefing": "mob_griefing"} + rule = aliases.get(words[1], words[1]) + if not re.fullmatch(r"(?:[a-z0-9_.-]+:)?[a-z0-9_./-]+", rule): + raise ValueError("Unknown legacy gamerule; add an explicit native mapping: " + words[1]) + if ":" not in rule: + rule = "minecraft:" + rule + super().send(" ".join(["gamerule", rule, *words[2:]])) + # The command must take effect, not merely be written to stdin. + shown = rule.removeprefix("minecraft:") + token = f"Gamerule {shown} is now set to: {words[2]}" if len(words) == 3 else f"Gamerule {shown} is currently set to: " + self.wait(token, 15) + + def validate_native_log(self, path): + diagnostics = require_clean_log(path) + if diagnostics: + write_json(path.with_suffix(".platform-diagnostics.json"), diagnostics) + + def close(self): + super().close() + logging_jar = self.native_runtime / f"libraries/net/minecraftforge/fmlloader/{FORGE_VERSION}/fmlloader-{FORGE_VERSION}.jar" + self.logging_configuration(logging_jar) + console = (self.native_runtime / f"console-{self.native_label}.log").read_text(encoding="utf-8") + for marker in (f"Starting minecraft server version {MINECRAFT}", + f"Forge mod loading, version {FORGE}, for MC {MINECRAFT} with MCP {MCP}", "Done ("): + if marker not in console: + raise AssertionError(f"Missing Forge 61 startup witness: {marker}") + + +def require_shared_crushing(document): + checks = document.get("sharedChecks", []) + if len(checks) != 1 or checks[0].get("id") != "basemetals/native-power-crushing" or checks[0].get("status") != "PASS": + raise AssertionError(f"Missing or failed Base Metals shared crushing witness: {checks}") + + +def require_oil_buckets(document, readiness): + require_probe_order(document, readiness) + expected = {f"{oil}/{check}" for oil in ("crude_oil", "refined_oil") + for check in ("world-roundtrip", "machine-roundtrip", "machine-below-bucket", + "machine-insufficient-room", "machine-full")} + checks = document.get("checks", []) + if document.get("gameMode") != "survival" or len(checks) != len(expected) or {row["id"] for row in checks} != expected: + raise AssertionError("Incomplete survival oil-bucket witness set") + failures = [row for row in checks if row.get("status") != "PASS"] + if failures: + raise AssertionError(f"Survival oil bucket failures: {[(row['id'], row.get('failure')) for row in failures]}") + + +def interop(args): + if not 1024 <= args.port <= 65535: + raise ValueError("An unprivileged localhost port is required") + runtime = output_path(args.output) + for path in (args.profile, args.seed, args.inventory, args.java, args.harness, args.power): + require_disjoint(runtime, path) + validate_hash(args.inventory, args.inventory_sha256) + inventory = read_json(args.inventory) + if inspect_seed(args.seed) != inventory: + raise AssertionError("Official Forge server differs from its sealed code inventory") + mods = read_mods(args) + for _, path, _ in mods.values(): + require_disjoint(runtime, path) + java, java_version = java21(args.java) + protected = fingerprint(args.seed) + expected = stage(runtime, args.seed, inventory, mods, args.port) + if args.oil_buckets: + (runtime / "advantage-native-oil-bucket.allow").touch() + receipt = runtime / "native12111-interop-results.json" + report = dict(status="RUNNING", qualificationStatus=EXPLORATORY, phases={}, minecraft=MINECRAFT, forge=FORGE, + profileSha256=digest(args.profile), inventorySha256=digest(args.inventory), + javaSha256=digest(java), javaVersion=java_version, jars=expected) + write_json(receipt, report) + try: + for phase in ("exercise", "restored"): + verify_runtime(runtime, inventory, expected) + server = Server(runtime, java, LAUNCHER, phase) + try: + server.send("forceload add 1023 1023 1100 1030") + ready = await_ready(server, runtime, (63, 63, 68, 64), f"interop-{phase}", minecraft=MINECRAFT) + server.send(f"advinterop {phase}") + server.wait(f"ADVINTEROP COMPLETE {phase}", 120) + document = read_json(runtime / "advantage-works-results" / f"native-interop-{phase}.json") + report["phases"][phase] = dict(readiness=ready, observation=document) + require_probe_order(document, ready) + require_checks(document, phase) + require_shared_crushing(document) + if args.oil_buckets and phase == "exercise": + server.send("advoilbuckets") + server.wait("ADVOILBUCKETS COMPLETE", 120) + buckets = read_json(runtime / "advantage-works-results/native-oil-buckets.json") + report["phases"][phase]["oilBuckets"] = buckets + require_oil_buckets(buckets, ready) + server.send("save-all flush") + server.wait("Saved the game", 30) + except BaseException as failure: + try: + server.close() + except BaseException as shutdown: + failure.add_note(f"Shutdown/log audit also failed: {shutdown!r}") + raise + else: + server.close() + write_json(receipt, report) + report["status"] = "PASS" + except BaseException as failure: + report.update(status="FAIL", failure=repr(failure), traceback=traceback.format_exc()) + raise + finally: + try: + verify_runtime(runtime, inventory, expected) + report["seedUnchanged"] = fingerprint(args.seed) == protected + if not report["seedUnchanged"]: + raise AssertionError("Protected Forge seed changed during the test") + except Exception as audit: + report.update(status="FAIL", auditError=repr(audit)) + write_json(receipt, report) + return int(report["status"] != "PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + for command in ("seal", "interop", "profile"): + sub = commands.add_parser(command) + if command in ("seal", "interop"): + sub.add_argument("--seed", type=Path, required=True) + sub.add_argument("--inventory", type=Path, required=True) + if command in ("interop", "profile"): + sub.add_argument("--profile", type=Path, default=BASE_PROFILE) + for key in ("output", "power", "harness"): + sub.add_argument("--" + key, type=Path, required=True) + for key in ("power", "harness"): + sub.add_argument("--" + key + "-sha256", required=True) + if command == "interop": + sub.add_argument("--java", type=Path, required=True) + sub.add_argument("--inventory-sha256", required=True) + sub.add_argument("--port", type=int, required=True) + sub.add_argument("--oil-buckets", action="store_true") + elif command == "profile": + sub.add_argument("--name", required=True) + for mod in ("steam", "electric", "mineralogy"): + sub.add_argument("--" + mod, type=Path) + sub.add_argument("--" + mod + "-sha256") + args = parser.parse_args() + if args.command == "profile": + materialize_profile(args) + return 0 + args.seed = args.seed.resolve(strict=True) + args.inventory = args.inventory.resolve() + if args.command == "seal": + seal(args.seed, args.inventory) + print(digest(args.inventory)) + return 0 + return interop(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_actor_comparison.py b/test-rig/migration/native12111_actor_comparison.py new file mode 100644 index 00000000..b4f8b285 --- /dev/null +++ b/test-rig/migration/native12111_actor_comparison.py @@ -0,0 +1,121 @@ +"""Read-only actor restart diagnosis; never replaces or upgrades the frozen verdict.""" + +import argparse +import hashlib +import json +import math +from pathlib import Path +import struct +import uuid + +import nbtlib + +import native117_machine_operations as frozen + + +CASES = {"steam/extra-explosive-0", "steam/extra-explosive-1"} +SOURCES_SHA256 = "DFE912C3B1E769B60DA2B4C5EA0B7F9171637B26DA640A9D85DE8CA683B30661" + + +def differences(before, after, path="$"): + if isinstance(before, dict) and isinstance(after, dict): + result = [] + for key in sorted(set(before) | set(after)): + name = path + "." + key + if key not in before or key not in after: + result.append(dict(path=name, before=before.get(key), after=after.get(key), + beforePresent=key in before, afterPresent=key in after)) + else: + result.extend(differences(before[key], after[key], name)) + return result + if (isinstance(before, list) and isinstance(after, list) and len(before) == len(after) == 3 + and before[0] == after[0] == "List" and before[1] == after[1] + and len(before[2]) == len(after[2])): + return [delta for index, (a, b) in enumerate(zip(before[2], after[2])) + for delta in differences(a, b, path + "[" + str(index) + "]")] + return [] if before == after else [dict(path=path, before=before, after=after)] + + +def actors(case): + result = {} + for row in case.get("entities", []): + identity = str(uuid.UUID(row["uuid"])) + if identity != row["uuid"] or identity in result: + raise AssertionError("Duplicate or noncanonical native actor UUID") + tag = nbtlib.parse_nbt(row["nbt"]) + if not isinstance(tag.get("UUID"), nbtlib.IntArray) or len(tag["UUID"]) != 4: + raise AssertionError("Missing native actor UUID array") + actual = uuid.UUID(bytes=struct.pack(">4i", *(int(part) for part in tag["UUID"]))) + if str(actual) != identity: + raise AssertionError("Native actor metadata and saved UUID disagree") + result[identity] = tag + return result + + +def stationary_cows(case_id, before, after): + return case_id in CASES and all( + isinstance(tag.get("id"), nbtlib.String) and str(tag["id"]) == "minecraft:cow" + and type(tag.get("NoAI")) is nbtlib.Byte and int(tag["NoAI"]) == 1 + and type(tag.get("NoGravity")) is nbtlib.Byte and int(tag["NoGravity"]) == 1 + for tag in (before, after)) + + +def explanation(case_id, before, after, delta): + if not stationary_cows(case_id, before, after): + return None + for index in range(3): + if delta["path"] != "$.Motion[" + str(index) + "]": + continue + a, b = before.get("Motion"), after.get("Motion") + if (isinstance(a, nbtlib.List) and isinstance(b, nbtlib.List) + and a.subtype is b.subtype is nbtlib.Double and len(a) == len(b) == 3 + and all(math.isfinite(float(value)) for value in (*a, *b)) + and abs(float(a[index])) > 10.0 and float(b[index]) == 0.0): + return "Entity.load: only this >10 native velocity component is clamped to zero" + if delta["path"] == "$.HurtByTimestamp": + if (type(before.get("HurtByTimestamp")) is nbtlib.Int + and type(after.get("HurtByTimestamp")) is nbtlib.Int + and int(before["HurtByTimestamp"]) >= 0 and int(after["HurtByTimestamp"]) == 0 + and all(key not in tag for tag in (before, after) + for key in ("last_hurt_by_mob", "ticks_since_last_hurt_by_mob"))): + return "LivingEntity.readAdditionalSaveData: absent relative hurt key rebases to unticked zero" + return None + + +def diagnose(fixture, inspected): + verdict = frozen.validate_restart(fixture, inspected) + actual = {row["id"]: row for row in inspected["cases"]} + observations = [] + for case in fixture["cases"]: + before, after = actors(case), actors(actual[case["id"]]) + for identity in sorted(set(before) | set(after)): + if identity not in before or identity not in after: + observations.append(dict(case=case["id"], uuid=identity, path="$", + beforePresent=identity in before, afterPresent=identity in after, + explanation=None)) + continue + for delta in differences(frozen.saved_actor_nbt(before[identity]), frozen.saved_actor_nbt(after[identity])): + observations.append(dict(case=case["id"], uuid=identity, **delta, + explanation=explanation(case["id"], before[identity], after[identity], delta))) + unknown = sum(row["explanation"] is None for row in observations) + return dict(diagnosticOnly=True, frozenRestartResult=verdict, actorDifferences=observations, + unexplainedActorDifferences=unknown, primarySourcesSha256=SOURCES_SHA256, + scope="Explains exact native load transitions only; no NBT writes or passing reclassification") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("runtime", type=Path) + args = parser.parse_args() + paths = [args.runtime / "machine-operation-fixture.json", + args.runtime / "advantage-works-results/machine-inspection-restarted.json"] + data = [path.read_bytes() for path in paths] + report = diagnose(*(json.loads(value) for value in data)) + report["inputSha256"] = {str(path.resolve()): hashlib.sha256(value).hexdigest().upper() + for path, value in zip(paths, data)} + print(json.dumps(report, indent=2, sort_keys=True)) + return int(report["frozenRestartResult"]["status"] != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_actor_fixtures.py b/test-rig/migration/native12111_actor_fixtures.py new file mode 100644 index 00000000..78488274 --- /dev/null +++ b/test-rig/migration/native12111_actor_fixtures.py @@ -0,0 +1,152 @@ +"""Explicit derived fixture copies: native-owned FML config authority and legal battery slots.""" + +import argparse +import base64 +import copy +import gzip +import io +from pathlib import Path +import shutil + +import nbtlib +from matrix import WORLD, digest, fingerprint, write_json +from native117 import output_path, require_disjoint +from native117_actor_io import bind, capture_actors, capture_dimensions +import native12111 as native +import native12111_actors as actors + +BATTERIES = {namespace + ":" + item for namespace in ("electricadvantage", "baseelectric") + for item in ("lithium_battery", "lead_acid_battery", "alkaline_battery", "nickel_hydride_battery")} + + +def legal_ender_batteries(player): + """Split only the four known artificial overstacked witnesses, retaining every item and tag.""" + inventory = player.get("EnderItems", ()) + slots = [int(item["Slot"]) for item in inventory] + if len(slots) != len(set(slots)) or any(not 0 <= slot < 27 for slot in slots): + raise AssertionError("Malformed historical EnderItems slots") + free = [slot for slot in range(27) if slot not in slots] + required = 0 + for item in inventory: + key = "Count" if "Count" in item else "count" + count = int(item[key]) + if str(item["id"]) in BATTERIES and count > 1: + if count != 3 or ("Count" in item and "count" in item): + raise AssertionError("Unreviewed battery overstack") + required += 2 + if len(free) < required: + raise AssertionError("Insufficient free EnderItems slots") + changes, appended = [], [] + for item in inventory: + key = "Count" if "Count" in item else "count" + count = int(item[key]) + if str(item["id"]) not in BATTERIES or count <= 1: + continue + original = copy.deepcopy(item) + item[key] = type(item[key])(1) + destinations = [int(item["Slot"])] + for _ in range(2): + sibling = copy.deepcopy(item) + destination = free.pop(0) + sibling["Slot"] = type(item["Slot"])(destination) + appended.append(sibling) + destinations.append(destination) + for sibling in (item, *appended[-2:]): + old_payload = {key: value for key, value in original.items() if key not in ("Slot", "Count", "count")} + new_payload = {key: value for key, value in sibling.items() if key not in ("Slot", "Count", "count")} + if old_payload != new_payload: + raise AssertionError("Fixture item metadata changed during legal splitting") + changes.append(dict(id=str(item["id"]), fromSlot=int(item["Slot"]), toSlots=destinations, preservedTotal=count)) + if appended: + inventory.extend(appended) + return changes + + +def derive(source, receipt, checksum, output, kind, legalize=False): + native.validate_hash(receipt, checksum) + approval = native.read_json(receipt) + before = fingerprint(source) + if Path(approval["source"]).resolve() != source or before != approval["sourceFingerprint"]: + raise AssertionError("Protected fixture differs from its explicit source receipt") + output = output_path(output) + require_disjoint(output, source) + require_disjoint(output, receipt) + output.mkdir(parents=True) + world_before = fingerprint(source / WORLD) + shutil.copytree(source / WORLD, output / WORLD) + config_evidence = output / "config-overlay-evidence" + config_evidence.mkdir() + for path in (source / "config").rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(source / "config") + target = config_evidence / "historical-fml.toml" if relative.as_posix() == "fml.toml" else output / "config" / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + name = "dimension" if kind == "dimensions" else "actor" + for filename in (name + "-fixture.json", "migration-fixture.json"): + shutil.copy2(source / filename, output / filename) + prefix = "dimension-semantic-" if kind == "dimensions" else "semantic-" + label = next(label for label in ("runtime-restart", "restarted", "source") if (source / (prefix + label + ".json")).is_file()) + record_name = ("dimensions-" if kind == "dimensions" else "actors-") + label + ".json" + record = native.read_json(source / record_name) + definition = native.read_json(source / (name + "-fixture.json")) + changes = {} + if legalize: + player_path = output / WORLD / "playerdata" / (definition["playerUuid"] + ".dat") + player = nbtlib.load(player_path) + edits = legal_ender_batteries(player) + if edits: + player.save() + changes["loosePlayer"] = edits + if record.get("playerAdapter"): + raw = nbtlib.File.parse(io.BytesIO(gzip.decompress(base64.b64decode(record["player"]["nbtBase64"], validate=True)))) + edits = legal_ender_batteries(raw) + if edits: + data = io.BytesIO() + raw.write(data) + record["player"]["nbtBase64"] = base64.b64encode(gzip.compress(data.getvalue(), mtime=0)).decode() + changes["serializerPlayer"] = edits + level_path = output / WORLD / "level.dat" + level = nbtlib.load(level_path) + if "Player" in level["Data"]: + edits = legal_ender_batteries(level["Data"]["Player"]) + if edits: + level.save() + changes["embeddedPlayer"] = edits + write_json(output / record_name, record) + capture = bind(capture_dimensions, semantic=actors.player_semantic) if kind == "dimensions" else capture_actors + write_json(output / (prefix + label + ".json"), capture(output, label)) + world_after = fingerprint(output / WORLD) + changed = [key for key in world_before if world_before[key] != world_after.get(key)] + allowed = {"level.dat", str(Path("playerdata") / (definition["playerUuid"] + ".dat"))} + if set(world_before) != set(world_after) or not set(changed) <= allowed or (not legalize and changed): + raise AssertionError("Unexpected world change in derived fixture") + if fingerprint(source) != before: + raise AssertionError("Protected source changed during derivation") + retained_fml = config_evidence / "historical-fml.toml" + report = dict(source=str(output), sourceFingerprint=fingerprint(output), originalSource=str(source), + originalReceiptSha256=checksum, originalUnchanged=True, exactWorldFileChanges=changed, + legalBatterySplits=changes, kind=kind, + configOverlay=dict(owner="Forge61 native12111.stage", authority="native target fml.toml; no historical config overwrite", + historicalFmlRetainedSha256=digest(retained_fml) if retained_fml.exists() else None, + nativeFmlContent="versionCheck = false\n"), + scope="Explicit derived fixture, not a new old-engine serializer qualification") + target_receipt = output.with_name(output.name + "-receipt.json") + if target_receipt.exists(): + raise FileExistsError(target_receipt) + write_json(target_receipt, report) + return target_receipt + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + for name in ("source", "receipt", "output"): + parser.add_argument("--" + name, type=lambda value: Path(value).resolve(), required=True) + parser.add_argument("--receipt-sha256", required=True) + parser.add_argument("--kind", choices=("actors", "dimensions"), required=True) + parser.add_argument("--legal-batteries", action="store_true") + args = parser.parse_args() + result = derive(args.source, args.receipt, args.receipt_sha256, args.output, args.kind, args.legal_batteries) + print(result) + print(digest(result)) diff --git a/test-rig/migration/native12111_actors.py b/test-rig/migration/native12111_actors.py new file mode 100644 index 00000000..35ee08ab --- /dev/null +++ b/test-rig/migration/native12111_actors.py @@ -0,0 +1,574 @@ +"""Check sealed historical player and dimension fixtures on Forge 61.""" + +import argparse +import builtins +import copy +from functools import partial +import math +from pathlib import Path +import shutil +import socket +import traceback +from types import SimpleNamespace + +import nbtlib + +import actors +import dimensions +from matrix import WORLD, digest, fingerprint, plain, write_json +from native117 import output_path, require_disjoint +from native117_actor_io import bind, capture_actors, capture_dimensions, semantic as historical_semantic +import native117_actors as historical +from native117_dimension_io import parse_dimension_nbt, project_entity +from native117_format import DIMENSIONS, dimension_id +import native117_entity_storage as entity_storage +import native117_helper_contract as helper_contract +import native117_stopped_entities as stopped +import native12111 as native + + +DATA_VERSION = 4671 +HELPER_CONTRACT_SHA = "EBFF575F4777A43C12255250B704C06FF700D5160AFCAFE7252508353CD430C4" +PLAYER_EQUIPMENT_SLOTS = {100: "feet", 101: "legs", 102: "chest", 103: "head", -106: "offhand"} +NATIVE_ATTRIBUTE_ALIASES = { + "minecraft:" + name: "minecraft:generic." + name + for name in ("max_health", "knockback_resistance", "movement_speed", "armor", + "armor_toughness", "follow_range", "attack_damage") +} +NATIVE_ATTRIBUTE_ALIASES["minecraft:jump_strength"] = "minecraft:horse.jump_strength" +BOAT_VARIANTS = frozenset(("oak", "spruce", "birch", "jungle", "acacia", "cherry", "dark_oak", "mangrove", "bamboo")) +WOLF_CONTROL_HASHES = { + 512: "809FA56A63DD762CD1046B8E29B68F25FCE3FC1789E1BA164B6AA9DB8BF0F63E", + 1343: "3069BED41A69603E09A6F586BF984D126B80B0DE68CB26C4294B5C665D10347F", +} +ENCHANTMENTS = { + "Enchantments": "minecraft:enchantments", + "StoredEnchantments": "minecraft:stored_enchantments", +} + + +def expected_components(value): + """Convert the historical expectation, never normalize a native observation away.""" + if isinstance(value, list): + return [expected_components(item) for item in value] + if not isinstance(value, dict): + return value + if "id" not in value or "Count" not in value: + return {key: expected_components(item) for key, item in value.items()} + if type(value["Count"]) is not int or value["Count"] <= 0 or "count" in value or "components" in value: + raise AssertionError("Malformed or ambiguous historical stack") + result = {key: item for key, item in value.items() if key not in ("Count", "tag")} + result["count"] = value["Count"] + custom = dict(value.get("tag", {})) + if "BlockEntityTag" in custom: + raise AssertionError("This actor adapter does not translate nested block-entity stack data") + components = {} + if "Damage" in custom: + components["minecraft:damage"] = custom.pop("Damage") + for key, component in ENCHANTMENTS.items(): + if key in custom: + enchantments = custom.pop(key) + levels = {item["id"]: item["lvl"] for item in enchantments} + if len(levels) != len(enchantments) or any(not isinstance(key, str) for key in levels): + raise AssertionError("Historical enchantments must have unique registry names") + components[component] = levels + if custom: + components["minecraft:custom_data"] = custom + if components: + result["components"] = components + return result + + +def exact_items(expected, observed, path=""): + if isinstance(expected, dict) and isinstance(observed, dict): + if "id" in expected and "count" in expected: + if expected != observed: + return [dict(path=path + ".exact-item", expected=expected, actual=observed)] + else: + return [finding for key in sorted(expected.keys() & observed.keys()) + for finding in exact_items(expected[key], observed[key], f"{path}.{key}")] + elif isinstance(expected, list) and isinstance(observed, list): + return [finding for index, (before, after) in enumerate(zip(expected, observed)) + for finding in exact_items(before, after, f"{path}[{index}]")] + return [] + + +def source_components(state, version): + if version in (3839, 3955): + from native12111_upgrade import expected_components as modern_components + return modern_components(state, source_version={3839: "1.20.6", 3955: "1.21.1"}[version]) + if version >= 3839: + raise ValueError("Unreviewed native component player source version: " + str(version)) + return expected_components(state) + + +def baseline(source, kind): + source_baseline = bind(historical.baseline, + capture_dimensions=bind(capture_dimensions, semantic=player_semantic)) + state, label = source_baseline(source, kind) + definition = native.read_json(source / ("dimension-fixture.json" if kind == "dimensions" else "actor-fixture.json")) + player = nbtlib.load(source / WORLD / "playerdata" / (definition["playerUuid"] + ".dat")) + return source_components(state, int(player.get("DataVersion", -1))), label + + +def expected_player_equipment(player): + """Apply native PlayerEquipmentFix's exact placement to expectations only.""" + result = copy.deepcopy(player) + if "Inventory" not in result: + return result + inventory = result["Inventory"] + if not isinstance(inventory, list): + raise ValueError("Malformed expected player inventory") + equipment = result.get("equipment", {}) + if not isinstance(equipment, dict): + raise ValueError("Malformed expected player equipment") + slots, remaining, moved = set(), [], {} + for item in inventory: + if not isinstance(item, dict) or type(item.get("Slot")) is not int: + raise ValueError("Missing or malformed expected inventory Slot") + slot = item["Slot"] + if slot in slots: + raise ValueError("Duplicate expected inventory Slot") + slots.add(slot) + name = PLAYER_EQUIPMENT_SLOTS.get(slot) + if name is None: + remaining.append(item) + else: + if "equipment" in result or not isinstance(item.get("id"), str) or type(item.get("count")) is not int or item["count"] <= 0: + raise ValueError("Ambiguous or malformed expected legacy equipment") + moved[name] = {key: value for key, value in item.items() if key != "Slot"} + result["Inventory"] = remaining + if moved: + result["equipment"] = moved + return result + + +def expected_state(value, kind, survival=False, adapter=True): + result = historical.expected_state(value, kind, survival, adapter) + # Conversion leaves the unopened loose file historical. Level.dat host data + # has already undergone DFU, and every read phase loads/saves the loose player. + for container in ("file", "adapter", "embedded"): + if container in result and (container != "file" or adapter): + result[container] = expected_player_equipment(result[container]) + if kind == "dimensions": + result[container] = expected_player_respawn(result[container]) + if kind == "dimensions": + for entity in result.get("entities", {}).values(): + expected_dimension_entity(entity) + return result + + +def expected_player_respawn(player): + result = copy.deepcopy(player) + fields = ("SpawnX", "SpawnY", "SpawnZ", "SpawnDimension", "SpawnAngle", "SpawnForced") + if not any(key in result for key in fields): + return result + if "respawn" in result or not all(type(result.get(key)) is int for key in fields[:3]): + raise ValueError("Incomplete or ambiguous expected respawn position") + yaw = result.get("SpawnAngle", 0.0) + forced = result.get("SpawnForced", 0) + if (type(yaw) not in (int, float) or not math.isfinite(yaw) + or type(forced) not in (int, bool) or forced not in (0, 1)): + raise ValueError("Malformed expected respawn angle or forced flag") + respawn = dict(pos=[result[key] for key in fields[:3]], + dimension=DIMENSIONS[dimension_id(result.get("SpawnDimension", 0))], yaw=yaw, pitch=0.0) + if forced: + respawn["forced"] = 1 + for key in fields: + result.pop(key, None) + result["respawn"] = respawn + return result + + +def expected_dimension_entity(entity): + if entity.get("id") in ("minecraft:boat", "minecraft:chest_boat"): + variant = entity.get("Type", "oak") + if variant not in BOAT_VARIANTS: + raise ValueError("Unproven historical boat variant") + chest = entity["id"] == "minecraft:chest_boat" + entity["id"] = "minecraft:" + variant + ("_chest" if chest else "") + ("_raft" if variant == "bamboo" else "_boat") + entity.pop("Type", None) + if entity.get("id") == "minecraft:horse": + moved = {} + for old, slot in (("SaddleItem", "saddle"), ("ArmorItem", "body")): + if old in entity: + if "equipment" in entity: + raise ValueError("Ambiguous expected horse equipment") + moved[slot] = entity.pop(old) + if moved: + entity["equipment"] = moved + for passenger in entity.get("Passengers", ()): + expected_dimension_entity(passenger) + + +def player_semantic(value, keys, registry): + if "SpawnX" in keys: + keys = (*keys, "SpawnAngle") + result = historical_semantic(value, keys, registry) + if "Inventory" in keys and "equipment" in value: + equipment = value["equipment"] + if not isinstance(equipment, dict): + raise ValueError("Malformed observed player equipment") + result["equipment"] = plain(equipment) + if "SpawnX" in keys and "respawn" in value: + if not isinstance(value["respawn"], dict): + raise ValueError("Malformed observed player respawn") + if any(key in value for key in ("SpawnX", "SpawnY", "SpawnZ", "SpawnDimension", "SpawnAngle", "SpawnForced")): + raise ValueError("Ambiguous observed native and historical respawn") + result["respawn"] = plain(value["respawn"]) + return result + + +def dimension_entity_state(value): + result = dimensions.entity_state(value) + if "equipment" in value: + if not isinstance(value["equipment"], dict): + raise ValueError("Malformed observed entity equipment") + if any(key in value for key in ("SaddleItem", "ArmorItem", "body_armor_item", "saddle", "ArmorItems", "HandItems")): + raise ValueError("Ambiguous observed native and historical entity equipment") + result["equipment"] = copy.deepcopy(value["equipment"]) + if "Passengers" in value: + result["Passengers"] = [dimension_entity_state(entry) for entry in value["Passengers"]] + return result + + +def leash_position(value): + if (len(value) != 3 or any(type(coordinate) is not int or not -(1 << 31) <= coordinate < (1 << 31) + for coordinate in value)): + raise ValueError("Expected the native leash knot's block-position int[3]") + return dict(zip(("X", "Y", "Z"), value)) + + +def dimension_entity_view(value): + if value.get("id") == "minecraft:horse": + if "leash" in value: + if "Leash" in value: + raise ValueError("Ambiguous saved horse leash") + value["Leash"] = leash_position(value.pop("leash")) + if "body_armor_item" in value: + if "ArmorItem" in value: + raise ValueError("Ambiguous saved horse armor") + value["ArmorItem"] = value.pop("body_armor_item") + for attribute in value.get("Attributes", ()): + if attribute["Name"] == "minecraft:generic.jump_strength": + if any(other["Name"] == "horse.jumpStrength" for other in value["Attributes"]): + raise ValueError("Ambiguous saved horse jump strength") + attribute["Name"] = "horse.jumpStrength" + for passenger in value.get("Passengers", ()): + dimension_entity_view(passenger) + return value + + +def native_dimension_nbt(entry): + parser = bind(parse_dimension_nbt, project_entity=lambda value, contexts: + project_entity(native_attribute_view(value), contexts)) + return dimension_entity_view(parser(entry)) + + +def native_attribute_view(value): + """Expose the new field spelling to the retained semantic oracle without changing values.""" + result = copy.deepcopy(value) + + def visit(entity): + if "attributes" in entity: + if "Attributes" in entity or not isinstance(entity["attributes"], list): + raise ValueError("Ambiguous native attribute list") + attributes, names = [], set() + for original in entity.pop("attributes"): + if not isinstance(original, dict) or not isinstance(original.get("id"), str): + raise ValueError("Malformed native attribute") + row = dict(original) + raw_name = row.pop("id") + name = NATIVE_ATTRIBUTE_ALIASES.get(raw_name, raw_name) + if name in names or "Name" in row or "Base" in row or "Modifiers" in row: + raise ValueError("Duplicate or ambiguous native attribute") + names.add(name) + row["Name"] = name + if "base" in row: + row["Base"] = row.pop("base") + if "modifiers" in row: + row["Modifiers"] = row.pop("modifiers") + attributes.append(row) + entity["Attributes"] = attributes + for passenger in entity.get("Passengers", ()): + visit(passenger) + visit(result) + return result + + +def capture_player_version(capture, runtime, label, evidence): + state = capture(runtime, label, evidence) + version = int(nbtlib.load(evidence / "player.dat").get("DataVersion", -1)) + if version != DATA_VERSION: + if label != "converted" or version >= DATA_VERSION: + raise AssertionError(f"{label}: native player file was not serialized as data version {DATA_VERSION}: {version}") + # The unopened loose file has not gone through PlayerDataStorage yet. + # Compare the retained source representation but keep its original file and version as evidence. + state["file"] = source_components(state["file"], version) + write_json(evidence / "player-format.json", dict(dataVersion=version, native=version == DATA_VERSION)) + return state + + +retain = bind(historical.retain, + capture_actors=partial(capture_player_version, bind(capture_actors, semantic=player_semantic)), + capture_dimensions=partial(capture_player_version, bind(capture_dimensions, semantic=player_semantic, + entity_state=dimension_entity_state, + parse_nbt=native_dimension_nbt))) +command = bind(historical.command, retain=retain) +release = bind(historical.release, command=command) + + +def helper_snapshot(record): + expected, rows, roots = entity_storage._snapshots(record) + for row in rows.values(): + if "leash" not in row: + continue + position = row["leash"] + if "Leash" in row or not isinstance(position, nbtlib.IntArray): + raise ValueError("Malformed or ambiguous native stopped leash") + if len(position) == 3: + row["Leash"] = nbtlib.Compound({key: nbtlib.Int(number) + for key, number in leash_position(list(map(int, position))).items()}) + elif len(position) != 4: + raise ValueError("Unexpected native stopped leash array length") + return expected, rows, roots + + +validate_helpers = bind(helper_contract.validate, native=SimpleNamespace( + _snapshots=helper_snapshot, _nbt=entity_storage._nbt, _decompress=entity_storage._decompress, + _identity=entity_storage._identity), CONTRACT_SHA=HELPER_CONTRACT_SHA, + POLICY="native12111-leash-knot-nosave-v1") +helper_adapter = SimpleNamespace(CONTRACT="native12111-helper-contract.json", LIFECYCLE=helper_contract.LIFECYCLE, + validate=validate_helpers, raw_helpers_absent=helper_contract.raw_helpers_absent) + + +def stop_audit_import(name, globals=None, locals=None, fromlist=(), level=0): + # Only this function's local helper import is adapted. The raw region audit + # still uses the original on-disk NBT and the module is never monkey-patched. + if level == 0 and name == "native117_helper_contract": + return helper_adapter + return builtins.__import__(name, globals, locals, fromlist, level) + + +audit_stopped = bind(stopped.audit_stopped, + __builtins__=dict(vars(builtins), __import__=stop_audit_import)) + + +def verdict(oracle, before, after): + result = oracle(before, after) + findings = exact_items(before, after) + findings.extend(exact_native_maps(before, after)) + result["differences"].extend(findings) + if findings: + result["status"] = "FAIL" + if "entity_status" in result and any(row["path"].startswith(".entities.") for row in findings): + result["entity_status"] = "FAIL" + if "player_status" in result and any(not row["path"].startswith(".entities.") for row in findings): + result["player_status"] = "FAIL" + return result + + +def exact_native_maps(expected, actual, path=""): + findings = [] + if isinstance(expected, dict) and isinstance(actual, dict): + for key, default in (("equipment", {}), ("respawn", None)): + before, after = expected.get(key, default), actual.get(key, default) + if before != after: + findings.append(dict(path=f"{path}.{key}.exact-map", expected=before, actual=after)) + for key in expected.keys() & actual.keys(): + findings.extend(exact_native_maps(expected[key], actual[key], f"{path}.{key}")) + elif isinstance(expected, list) and isinstance(actual, list): + for index, (before, after) in enumerate(zip(expected, actual)): + findings.extend(exact_native_maps(before, after, f"{path}[{index}]")) + return findings + + +def assess_native(before, after, strict, proof, scope, adapter): + result = dict(status=strict["status"], rawStrictStatus=strict["status"], + rawStrictResult=copy.deepcopy(strict), nativeDifferences=[]) + if strict["status"] == "PASS" or proof is None: + return result + expected = copy.deepcopy(before) + wolf = expected.get("entities", {}).get("passenger-wolf", {}) + actual = after.get("entities", {}).get("passenger-wolf", {}) + maximum = wolf.get("Attributes", {}).get("generic.maxHealth", {}) + if (wolf.get("id") != "minecraft:wolf" or actual.get("id") != wolf["id"] + or wolf.get("Health") != 8 or maximum.get("Base") != 20 + or any(key not in wolf or actual.get(key) != wolf[key] + for key in ("UUIDMost", "UUIDLeast", "OwnerUUID"))): + return result + # These exact changes were independently reproduced by vanilla DFU and native + # Wolf load/save on a classpath that contains no Advantage classes. + controls = {} + for version, checksum in WOLF_CONTROL_HASHES.items(): + path = Path(proof) / f"legacy-{version}-receipt.json" + native.validate_hash(path, checksum) + controls[str(version)] = dict(path=str(path), sha256=checksum, observation=native.read_json(path)) + wolf["Health"] = 16 + maximum["Base"] = 40 + checked = verdict(dimensions.verdict, expected, after) + if checked["status"] == "PASS": + result.update(status="PASS_WITH_NATIVE_DIFFERENCE", allOtherFieldsStrictMatch=True, + controlledProof=controls, nativeDifferences=copy.deepcopy(strict["differences"])) + return result + + +def summarize_native(checks, assessments): + failures, native_changes = [], [] + for key, check in checks.items(): + if check["status"] == "PASS": + continue + assessment = assessments.get(key) + if (assessment is None or assessment.get("rawStrictResult") != check + or assessment.get("status") != "PASS_WITH_NATIVE_DIFFERENCE" + or assessment.get("allOtherFieldsStrictMatch") is not True): + failures.append(key) + else: + native_changes.append(dict(check=key, assessment=assessment)) + return dict(status="FAIL" if not checks or failures else + "PASS_WITH_NATIVE_DIFFERENCE" if native_changes else "PASS", + rawStrictStatus="FAIL" if native_changes or failures else "PASS", + failures=failures, nativeDifferences=native_changes) + + +def copy_fixture(source, runtime): + shutil.copytree(source / WORLD, runtime / WORLD) + shutil.copy2(source / "migration-fixture.json", runtime / "migration-fixture.json") + config = source / "config" + if config.is_dir(): + for item in config.rglob("*"): + destination = runtime / "config" / item.relative_to(config) + if item.is_file() and destination.exists(): + raise FileExistsError(f"Retained fixture config conflicts with native runtime: {destination}") + shutil.copytree(config, runtime / "config", dirs_exist_ok=True) + + +run_route = bind(historical.run_route, baseline=baseline, expected_state=expected_state, command=command, release=release, + copy_fixture=copy_fixture, assess=assess_native, + summarize_checks=summarize_native, + native117_dimension_assessment=SimpleNamespace(__file__=__file__), + actors=SimpleNamespace(compare=partial(verdict, actors.compare), freeze_result=actors.freeze_result, + tick_result=actors.tick_result, backup_state=actors.backup_state), + dimensions=SimpleNamespace(verdict=partial(verdict, dimensions.verdict), + tick_result=dimensions.tick_result)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("fixture", "fixture-receipt", "profile", "seed", "inventory", "java", "output"): + parser.add_argument("--" + name, type=lambda value: Path(value).resolve(), required=True) + parser.add_argument("--fixture-receipt-sha256", required=True) + parser.add_argument("--inventory-sha256", required=True) + parser.add_argument("--kind", choices=("actors", "dimensions"), required=True) + parser.add_argument("--native-wolf-proof", type=lambda value: Path(value).resolve(strict=True)) + parser.add_argument("--source-stopped-reference", type=lambda value: Path(value).resolve(strict=True)) + parser.add_argument("--source-stopped-reference-sha256") + parser.add_argument("--port", type=int, required=True) + args = parser.parse_args() + if not 26450 <= args.port <= 65535: + parser.error("Use a disposable localhost port at or above 26450") + + output = output_path(args.output) + source = args.fixture.resolve(strict=True) + seed, inventory, mods = native.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + java, version = native.java21(args.java) + for path in (source, args.fixture_receipt, args.profile, seed, args.inventory, java, + *(entry[1] for entry in mods.values())): + require_disjoint(output, path) + native.validate_hash(args.fixture_receipt, args.fixture_receipt_sha256) + approved = native.read_json(args.fixture_receipt) + before = fingerprint(source) + if not before or Path(approved["source"]).resolve() != source or approved["sourceFingerprint"] != before: + raise AssertionError("Historical fixture no longer matches its approved receipt") + source_reference, reference_before, reference_helper, active_route = None, None, None, run_route + if args.source_stopped_reference is not None or args.source_stopped_reference_sha256 is not None: + if args.kind != "actors" or args.source_stopped_reference is None or args.source_stopped_reference_sha256 is None: + raise ValueError("Stopped source reference requires actors mode and explicit file plus SHA-256") + import native12111_stopped_actor_reference as reference_helper + require_disjoint(output, args.source_stopped_reference.parent) + source_reference = reference_helper.validate(args.source_stopped_reference, args.source_stopped_reference_sha256, + source, args.fixture_receipt_sha256) + reference_before = fingerprint(args.source_stopped_reference.parent) + def stopped_baseline(actual_source, kind): + if actual_source.resolve() != source or kind != "actors": + raise AssertionError("Stopped source reference bound to a different route") + return copy.deepcopy(source_reference), "sealed-source-stopped-disk" + active_route = bind(run_route, baseline=stopped_baseline) + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + seed_before = fingerprint(seed) + expected_jars = {path.name: sha.upper() for _, path, sha in mods.values()} + runtime = output / "server" + report = output / "native12111-actors-results.json" + runner = Path(__file__) + result = dict(status="RUNNING", kind=args.kind, source=str(source), realClientLogin="NOT_TESTED", + qualificationStatus=native.EXPLORATORY, minecraft=native.MINECRAFT, forge=native.FORGE, + fixtureReceiptSha256=digest(args.fixture_receipt), profileSha256=digest(args.profile), + inventorySha256=digest(args.inventory), runnerSha256=digest(runner), + historicalOracleSha256=digest(Path(historical.__file__)), javaSha256=digest(java), + javaVersion=version, port=args.port, jars=expected_jars, checks={}) + output.mkdir() + shutil.copy2(runner, output / runner.name) + if source_reference is not None: + result["stoppedSourceReference"] = dict(path=str(args.source_stopped_reference), + sha256=digest(args.source_stopped_reference), helperSha256=digest(Path(reference_helper.__file__)), + targetObservationsUsed=False, sealedBeforeTargetLoad=True) + shutil.copy2(Path(reference_helper.__file__), output / "stopped-reference-builder-source.py") + write_json(report, result) + + def stage(destination, target, port): + native.stage(destination, *target, port) + return native.LAUNCHER + + class Server(native.Server): + def __init__(self, *values): + native.verify_runtime(runtime, inventory, expected_jars) + super().__init__(*values) + + def close(self): + try: + super().close() + finally: + native.verify_runtime(runtime, inventory, expected_jars) + + try: + args.target_manifest, args.java17 = args.profile, java + result["checks"] = active_route( + source, runtime, (seed, inventory, mods), args, stage=stage, server_type=Server, + stop_audit=partial(audit_stopped, minecraft_version=native.MINECRAFT, + data_version=DATA_VERSION, stopping_phase="ServerStoppingEvent"), + check_log=native.require_clean_log, minecraft=native.MINECRAFT) + checks = result["checks"] + result["status"] = "PASS" if checks and all(c["status"] == "PASS" for c in checks.values()) else "FAIL" + if args.kind == "dimensions": + result["nativeAssessment"] = native.read_json(runtime / "native117-platform-assessment.json") + result["status"] = result["nativeAssessment"]["assessment"]["status"] + except BaseException as failure: + result.update(status="FAIL", error=repr(failure), traceback=traceback.format_exc()) + checkpoint = runtime / "native117-comparison.json" + if checkpoint.is_file(): + result["checks"] = native.read_json(checkpoint) + raise + finally: + result["sourceUnchanged"] = fingerprint(source) == before + result["seedUnchanged"] = fingerprint(seed) == seed_before + result["inputsUnchanged"] = all(digest(path) == sha for path, sha in ( + (args.profile, result["profileSha256"]), (args.inventory, result["inventorySha256"]), + (args.fixture_receipt, result["fixtureReceiptSha256"]), (java, result["javaSha256"]), + (runner, result["runnerSha256"]), (Path(historical.__file__), result["historicalOracleSha256"]), + *((path, sha.upper()) for _, path, sha in mods.values()), + )) + if not all(result[key] for key in ("sourceUnchanged", "seedUnchanged", "inputsUnchanged")): + result["status"] = "FAIL" + if source_reference is not None: + result["stoppedSourceReferenceUnchanged"] = ( + fingerprint(args.source_stopped_reference.parent) == reference_before + and digest(Path(reference_helper.__file__)) == result["stoppedSourceReference"]["helperSha256"]) + if not result["stoppedSourceReferenceUnchanged"]: + result["status"] = "FAIL" + write_json(report, result) + return int(result["status"] not in ("PASS", "PASS_WITH_NATIVE_DIFFERENCE")) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_broad.py b/test-rig/migration/native12111_broad.py new file mode 100644 index 00000000..59c20065 --- /dev/null +++ b/test-rig/migration/native12111_broad.py @@ -0,0 +1,126 @@ +"""Commission all 21 supported BaseMetals-present bays and twelve native drill witnesses.""" + +import argparse +from pathlib import Path +from types import SimpleNamespace +import traceback + +from matrix import digest, fingerprint, write_json +from native117 import output_path, require_disjoint +from native117_broad import DRILL_BAYS, campaign_document, drill_cases, run_profile +from native117_readiness import await_ready +import native12111 as native + + +def supported_mode(value): + if value != "packaged-current": + raise argparse.ArgumentTypeError( + f"{value!r} is unsupported for Minecraft 1.21.11: BaseMetals is mandatory. " + "Use packaged-current with the complete pinned family profile.") + return value + + +class Adapter: + Server = native.Server + MINECRAFT, FORGE, FLAT_SETTINGS = native.MINECRAFT, native.FORGE, native.FLAT_SETTINGS + verify_runtime = staticmethod(native.audit_staged) + require_clean_log = staticmethod(native.require_clean_log) + + @staticmethod + def stage(runtime, target, port): + seed, inventory, entries = target + native.stage(runtime, seed, inventory, { + key: (item["version"], item["path"], item["sha256"]) for key, item in entries.items() + }, port) + return native.LAUNCHER + + @staticmethod + def drill_tag(marker): + return ('{components:{"minecraft:custom_data":{AdvBayWitness:' + marker + + '}},Items:[{Slot:0b,id:"minecraft:diamond",count:7,' + 'components:{"minecraft:custom_data":{AdvBayStock:' + marker + '}}}]}') + + @staticmethod + def drill_data(row): + data = native.compat_tile_data(row) + if "AdvBayWitness" in data.get("ForgeData", {}): + raise AssertionError("Broad witness must come from the native tile component") + marker = data.get("components", {}).get("minecraft:custom_data", {}).get("AdvBayWitness") + if marker is not None: + data["ForgeData"] = {"AdvBayWitness": marker} + return data + + @staticmethod + def drill_tickets(server, runtime, stations, *, add): + for station in stations: + if station["id"] not in DRILL_BAYS: + continue + cells = [case[cell] for case in drill_cases(station) for cell in ("origin", "rear")] + bounds = (min(x for x, _, _ in cells), min(z for _, _, z in cells), + max(x for x, _, _ in cells), max(z for _, _, z in cells)) + verb = "add" if add else "remove" + server.send(f"forceload {verb} " + " ".join(map(str, bounds))) + if add: + await_ready(server, runtime, tuple(value >> 4 for value in bounds), + f"broad-seed-{station['id']}", minecraft=native.MINECRAFT) + else: + server.wait("Unmarked", 30) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for key in ("profile", "seed", "inventory", "java", "output"): + parser.add_argument("--" + key, type=lambda value: Path(value).resolve(), required=True) + parser.add_argument("--inventory-sha256", required=True) + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--run-seconds", type=int, default=8) + parser.add_argument("--mode", type=supported_mode, default="packaged-current", metavar="packaged-current", + help="Supported BaseMetals-present profile (default); both/packaged-decoupled are refused") + args = parser.parse_args() + if not 1 <= args.run_seconds <= 60 or not 1024 <= args.port < 65535: + parser.error("Use a bounded duration and an unprivileged localhost port") + seed, inventory, mods = native.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + if "basemetals" not in mods: + parser.error("BaseMetals is mandatory for Minecraft 1.21.11; pin it in the packaged-current profile") + output = output_path(args.output) + java, version = native.java21(args.java) + for path in (args.profile, seed, args.inventory, java, *(item[1] for item in mods.values())): + require_disjoint(output, path) + protected = fingerprint(seed) + entries = {key: dict(id=key, version=value[0], path=value[1], file=value[1].name, sha256=value[2]) + for key, value in mods.items()} + spec = native.RIG / "src/main/resources/assets/advantageworkstest/works/works-1.13.2.json" + stations = native.read_json(spec)["stations"] + if len(stations) != 21: + raise AssertionError("The complete 21-bay definition is required") + names = (args.mode,) + proof = dict(qualificationStatus=native.EXPLORATORY, minecraft=native.MINECRAFT, forge=native.FORGE, + profileSha256=digest(args.profile), inventorySha256=digest(args.inventory), + stationDefinitionSha256=digest(spec), javaVersion=version, javaSha256=digest(java), + jars={key: item["sha256"] for key, item in entries.items()}) + receipt = output / "native12111-broad-results.json" + results = {} + for index, name in enumerate(names): + write_json(receipt, campaign_document(names, results, proof, finished=False)) + try: + results[name] = run_profile(SimpleNamespace(java17=java, run_seconds=args.run_seconds), None, + output, name, args.port + index, stations, + native118_target=(seed, inventory, entries), native_adapter=Adapter) + except Exception: + results[name] = dict(status="FAIL", profile=name, error=traceback.format_exc()) + try: + expected = {item["file"]: item["sha256"] for item in entries.values()} + native.verify_runtime(output / name, inventory, expected) + except Exception: + results[name].update(status="FAIL", custodyError=traceback.format_exc()) + print(name, results[name]["status"], flush=True) + final = campaign_document(names, results, proof, finished=True) + final["seedUnchanged"] = fingerprint(seed) == protected + if not final["seedUnchanged"]: + final["status"] = "FAIL" + write_json(receipt, final) + return int(final["status"] != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_endurance.py b/test-rig/migration/native12111_endurance.py new file mode 100644 index 00000000..a3c05b28 --- /dev/null +++ b/test-rig/migration/native12111_endurance.py @@ -0,0 +1,250 @@ +"""Run the measured Works feed, conservation and cold-restart protocol on Forge 61.""" + +import argparse +from collections import Counter +import json +from pathlib import Path +import time + +import endurance +from matrix import WORLD, digest, fingerprint, write_json +from native117 import output_path, require_disjoint +from native117_broad import read_json +from native117_endurance import NativeEnduranceRig, configure_new_fresh_fixture, prove_fresh_fixture_before_build +from native117_identity import target_id +import native12111 as native +from native12111_upgrade import comparison, copy_source +import semantic_machine_audit + + +class EnduranceHold(Exception): + pass + + +def check_hold(root): + if (root / "hold-endurance.request").is_file(): + raise EnduranceHold("Operator requested hold") + + +def measure_batch(documents, definitions): + # Use a derived view for the frozen oracle; raw checkpoint documents stay intact. + def project(row): + if "nbt" not in row: + return row + view = dict(row, nbt=native.compat_tile_nbt(row).snbt()) + view.pop("nbtBase64", None) + return view + + try: + views = { + station: dict(document, snapshot=[project(row) for row in document["snapshot"]]) + if document.get("snapshot") else document + for station, document in documents.items() + } + except (AssertionError, KeyError, ValueError, TypeError) as error: + return dict(status="FAIL", failures=[str(error)]) + return semantic_machine_audit.endurance_batch(views, definitions, identity=target_id) + + +def stop_at_checkpoint(rig): + # Keep ticking buffers and timers identical between observation and disk save. + rig.command("tick freeze", "The game is frozen") + rig.checkpoint("restart-resumed") + rig.batch("restart-resumed") + statuses = rig.structure() + rig.command("save-all flush", "Saved the game") + rig.shutdown() + return statuses + + +class Rig(NativeEnduranceRig): + server_type = native.Server + + def __init__(self, name, runtime, java, launcher, definitions, inventory, expected): + super().__init__(name, runtime, java, launcher, definitions) + self.code_inventory, self.expected_jars = inventory, expected + + def start(self, build=False): + # Prime an inert rig. Otherwise old start-action stock can enter a conveyor + # while console commands clear and replace the finite measured inputs. + self.command("tick freeze", "The game is frozen") + try: + return super().start(build=build) + finally: + self.command("tick unfreeze", "The game is running normally") + + def seed(self, initial=False): + if initial: + return super().seed(initial=True) # start() already holds the native tick gate. + self.command("tick freeze", "The game is frozen") + try: + return super().seed(initial=False) + finally: + self.command("tick unfreeze", "The game is running normally") + + def checkpoint(self, label): + for station in endurance.CHECKED: + self.command(f"advworks checkpoint {station} {label}", "Checkpoint written for " + station) + document = read_json(self.runtime / "advantage-works-results" / f"{station}-checkpoint-{label}.json") + self.documents[station], self.snapshots[station] = document, native.parse_endurance_snapshot(document) + + def batch(self, label): + measured = measure_batch( + {station: self.documents[station] for station in ("S-04", "E-03", "W-04", "W-03")}, self.definitions) + with (self.runtime / f"semantic-batch-{label}.json").open("x", encoding="utf-8") as stream: + json.dump(measured, stream, indent=2) + endurance.require(measured["status"] == "PASS", "Independent batch measurements failed: " + str(measured)) + return endurance.Rig.batch(self, label, power="basepower") + + def boot(self, phase): + native.verify_runtime(self.runtime, self.code_inventory, self.expected_jars) + super().boot(phase) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("profile", "seed", "inventory", "output", "java"): + parser.add_argument("--" + name, required=True, type=lambda value: Path(value).resolve()) + parser.add_argument("--inventory-sha256", required=True) + parser.add_argument("--seconds", type=int, default=1800) + parser.add_argument("--retained", type=lambda value: Path(value).resolve(strict=True)) + parser.add_argument("--retained-receipt-sha256") + parser.add_argument("--port", type=int, default=25658) + args = parser.parse_args() + endurance.require(args.seconds >= 60 and args.seconds % 60 == 0, "Use whole one-minute batches") + if not 1024 <= args.port < 65535: + parser.error("Use unprivileged consecutive localhost ports") + root = output_path(args.output) + target = native.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + java, version = native.java21(args.java) + seed_before = fingerprint(args.seed) + for protected in (args.profile, args.seed, args.inventory, java, *(item[1] for item in target[2].values())): + require_disjoint(root, protected) + source_before = None + if args.retained: + require_disjoint(root, args.retained) + receipts = list(args.retained.glob("native12111-*-upgrade-results.json")) + if len(receipts) != 1 or args.retained_receipt_sha256 is None: + raise ValueError("Pin the exact passing retained migration receipt") + native.validate_hash(receipts[0], args.retained_receipt_sha256) + prior = read_json(receipts[0]) + if (prior["status"] != "PASS" or prior["profileSha256"] != digest(args.profile) + or prior["inventorySha256"] != digest(args.inventory) + or digest(args.retained / "migration-restarted.json") != prior["phases"]["restarted"]["snapshotSha256"]): + raise AssertionError("Retained input does not match the passing pinned migration") + source_before = fingerprint(args.retained) + spec = native.RIG / "src/main/resources/assets/advantageworkstest/works/works-1.13.2.json" + definitions = {row["id"]: row for row in read_json(spec)["stations"]} + report = dict(status="RUNNING", minecraft=native.MINECRAFT, forge=native.FORGE, + qualificationStatus=native.EXPLORATORY, javaVersion=version, javaSha256=digest(java), + jars={key: item[2] for key, item in target[2].items()}, duration_seconds=args.seconds, + interval_seconds=60, profileSha256=digest(args.profile), inventorySha256=digest(args.inventory), + stationDefinitionSha256=digest(spec), active_stations=endurance.ACTIVE, + checked_stations=endurance.CHECKED, profiles={}, retained=str(args.retained) if args.retained else None) + receipt = root / "native12111-endurance-results.json" + write_json(receipt, report) + rigs = [] + try: + cases = (("fresh12111", None), ("retained-upgrade-native12111", args.retained)) if args.retained else (("fresh12111", None),) + for index, (name, source) in enumerate(cases): + check_hold(root) + runtime = root / name + expected = native.stage(runtime, *target, args.port + index) + rig = Rig(name, runtime, java, native.LAUNCHER, definitions, target[1], expected) + rigs.append(rig) + if source: + copy_source(source, runtime) + write_json(runtime / "migration-fixture.json", read_json(source / "migration-fixture.json")) + rig.boot("retained-before-build") + rig.server.snapshot("retained-before-build") + rig.shutdown() + preservation = comparison(read_json(source / "migration-restarted.json"), + read_json(runtime / "migration-retained-before-build.json"), modern=False) + write_json(runtime / "retained-comparison.json", preservation) + endurance.require(preservation["status"] == "PASS", "Retained fixture changed before operation") + else: + creation = configure_new_fresh_fixture(runtime) + (runtime / "advantage-works.allow").touch() + (runtime / "migration-operate.allow").touch() + rig.boot("endurance") + if source is None: + report["fresh_fixture_proof"] = prove_fresh_fixture_before_build(rig, creation) + rig.start(build=True) + initial = {rig.name: rig.documents["W-01"]["worldTime"] for rig in rigs} + started = time.monotonic() + cycles = args.seconds // 60 + for cycle in range(1, cycles + 1): + deadline = started + cycle * 60 + while time.monotonic() < deadline: + check_hold(root) + for rig in rigs: + endurance.require(rig.server.process.poll() is None, rig.name + ": server died") + time.sleep(min(1, max(0, deadline - time.monotonic()))) + for rig in rigs: + check_hold(root) + label = f"cycle-{cycle:03}" + rig.checkpoint(label) + rig.batch(label) + if cycle < cycles: + rig.seed() + report["profiles"][rig.name] = dict(status="RUNNING", completed_batches=len(rig.ledger), + latest_world_time=rig.documents["W-01"]["worldTime"]) + write_json(receipt, report) + print(f"{cycle * 60}s: all requested profiles passed batch {cycle}", flush=True) + elapsed = time.monotonic() - started + ticks = {rig.name: rig.documents["W-01"]["worldTime"] - initial[rig.name] for rig in rigs} + for rig in rigs: + check_hold(root) + endurance.require(ticks[rig.name] >= args.seconds * 18, rig.name + ": fewer than 18 game ticks per second") + rig.structure() + rig.checkpoint("before-restart") + rig.batch("before-restart") + rig.command("save-all flush", "Saved the game") + rig.shutdown() + rig.boot("endurance-restart") + rig.checkpoint("after-restart") + rig.batch("after-restart") + rig.structure() + rig.seed() + resume_deadline = time.monotonic() + 60 + while time.monotonic() < resume_deadline: + check_hold(root) + time.sleep(min(1, max(0, resume_deadline - time.monotonic()))) + for rig in rigs: + check_hold(root) + statuses = stop_at_checkpoint(rig) + totals = Counter() + for batch in rig.ledger: + if batch["label"].startswith("cycle-"): + totals.update(batch["outputs"]) + report["profiles"][rig.name] = dict(status="PASS", elapsed_seconds=elapsed, endurance_batches=cycles, + game_ticks=ticks[rig.name], verified_checkpoints=len(rig.ledger), + endurance_outputs=dict(totals), station_statuses=statuses, + restart="PASS", resumed_processing="PASS") + report["status"] = "PASS" if args.seconds >= 1800 else "SMOKE_PASS_NOT_ENDURANCE" + except EnduranceHold as hold: + report.update(status="HELD", holdReason=str(hold)) + except BaseException as error: + report.update(status="FAIL", failure=repr(error)) + raise + finally: + for rig in rigs: + try: + rig.shutdown() + native.verify_runtime(rig.runtime, rig.code_inventory, rig.expected_jars) + except Exception as error: + report.update(status="FAIL", shutdown_or_audit_error=repr(error)) + report["seedUnchanged"] = fingerprint(args.seed) == seed_before + if not report["seedUnchanged"]: + report["status"] = "FAIL" + if args.retained: + report["retained_unchanged"] = fingerprint(args.retained) == source_before + if not report["retained_unchanged"]: + report["status"] = "FAIL" + write_json(receipt, report) + print(report["status"] + ": " + str(receipt), flush=True) + return 2 if report["status"] == "HELD" else int(report["status"] == "FAIL") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_endurance_replay.py b/test-rig/migration/native12111_endurance_replay.py new file mode 100644 index 00000000..d6db17b4 --- /dev/null +++ b/test-rig/migration/native12111_endurance_replay.py @@ -0,0 +1,559 @@ +"""Seal before endurance, freeze stopped files, then replay Forge 61 evidence. + +The external seal and freeze SHA pins are mandatory. A retrospective seal +allows useful replay but never a qualified PASS or a claim of pre-run custody. +No command launches Minecraft or modifies a source world. Raw equality requires +a final restart-resumed checkpoint under tick freeze, followed by save/stop. +""" + +import argparse +import base64 +from contextlib import ExitStack, contextmanager +from datetime import datetime, timezone +import hashlib +import json +import math +import os +from pathlib import Path +import re +import sys +import time + +import nbtlib +from nbtlib.literal import parser as literal_parser + +import audit_native1201_endurance as checkpoint_audit +from audit_native12111_endurance import Forge61 +from audit_endurance import expected_inventory_acknowledgements +import endurance +from matrix import WORLD +from native117_actor_io import bind +from native117_entity_storage import _decompress, _nbt, _payloads +from native117_format import decode_palette +from native117_machine_operations import typed_nbt +from native117_machine_saved import state +import native12111 as native + +DATA_VERSION = 4671 +MODES = ("fresh12111", "retained-upgrade-native12111") +RECEIPT = "native12111-endurance-results.json" +MAX_FILE = 256 * 1024 * 1024 +MAX_JSON = 16 * 1024 * 1024 +MAX_FILES = 16384 +MAX_TOTAL = 8 * 1024 * 1024 * 1024 +MAX_ROWS = 65536 +REGION = re.compile(r"r\.(-?\d+)\.(-?\d+)\.mca") + + +def require(condition, message): + if not condition: + raise AssertionError(message) + + +def sha(data): + return hashlib.sha256(data).hexdigest().upper() + + +def pin(data, expected): + require(isinstance(expected, str) and re.fullmatch(r"[a-fA-F0-9]{64}", expected) + and sha(data) == expected.upper(), "External SHA-256 pin differs") + + +def regular(path): + path = Path(path).absolute() + for part in (path, *path.parents): + if part.is_symlink() or getattr(part, "is_junction", lambda: False)(): + raise ValueError("Aliases are not frozen evidence: " + str(part)) + if not path.is_file(): + raise ValueError("Expected regular file: " + str(path)) + if path.stat().st_nlink != 1: + raise ValueError("Aliased hard-linked evidence: " + str(path)) + return path + + +class Budget: + def __init__(self, seconds=300): + if type(seconds) is not int or not 1 <= seconds <= 900: + raise ValueError("Replay budget must be 1..900 seconds") + self.deadline = time.monotonic() + seconds + self.bytes = 0 + self.locked = {} + + def check(self): + if time.monotonic() > self.deadline: + raise TimeoutError("Bounded replay deadline exceeded") + + def read(self, path, limit=MAX_FILE): + self.check() + path = regular(path) + size = path.stat().st_size + if size > limit: + raise ValueError("File exceeds audit bound: " + str(path)) + if path.resolve() in self.locked: + stream = self.locked[path.resolve()] + stream.seek(0) + data = stream.read(size + 1) + else: + with path.open("rb") as stream: + data = stream.read(size + 1) + self.bytes += len(data) + if len(data) != size or len(data) > limit or self.bytes > MAX_TOTAL: + raise ValueError("Changed file or total audit byte bound exceeded") + self.check() + return data + + def digest(self, path): + return sha(self.read(path)) + + def json(self, path): + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate JSON key: " + key) + result[key] = value + return result + + def invalid(value): + raise ValueError("Nonfinite JSON: " + value) + + return json.loads(self.read(path, MAX_JSON), object_pairs_hook=unique, parse_constant=invalid) + + +def write_new(path, document): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("x", encoding="utf-8") as stream: + json.dump(document, stream, indent=2, sort_keys=True, allow_nan=False) + stream.write("\n") + + +def disjoint(output, *inputs): + output = Path(output).resolve() + for path in inputs: + path = Path(path).resolve() + if output.is_relative_to(path) or path.is_relative_to(output): + raise ValueError("Output overlaps a protected input") + if output.exists(): + raise FileExistsError(output) + + +def tree(root, budget): + root = Path(root).absolute() + pending, result, entries = [root], {}, 0 + while pending: + budget.check() + for path in sorted(pending.pop().iterdir()): + entries += 1 + if entries > MAX_FILES: + raise ValueError("Frozen tree entry bound exceeded") + if path.is_symlink() or getattr(path, "is_junction", lambda: False)(): + raise ValueError("Alias in frozen tree") + if path.is_dir(): + pending.append(path) + else: + result[path.relative_to(root).as_posix()] = budget.digest(path) + return result + + +def oracle_paths(): + # Imported local transitive helpers include both independent semantic oracles. + root = Path(__file__).resolve().parent + result = {Path(__file__).resolve()} + for module in list(sys.modules.values()): + path = getattr(module, "__file__", None) + if path and Path(path).suffix == ".py" and ( + Path(path).resolve().parent == root or getattr(module, "__name__", "").startswith("nbtlib")): + result.add(Path(path).resolve()) + return sorted(result) + + +def seal(profile, profile_sha256, specification, inventory, inventory_sha256, java, budget, *, retrospective=False): + pin(budget.read(profile), profile_sha256) + pin(budget.read(inventory), inventory_sha256) + document = budget.json(profile) + require(document["minecraftVersion"] == native.MINECRAFT + and document["forge"]["version"] == native.FORGE_VERSION, "Wrong native runtime profile") + mods = document["mods"] + require(len(mods) == 6 and {row["id"] for row in mods} == { + "basepower", "basesteam", "baseelectric", "advantageworkstest", "basemetals", "orespawn"}, + "Seal requires all six production jars") + for row in mods: + require(Path(row["path"]).name == row["file"], "Artifact filename differs from profile") + pin(budget.read(row["path"]), row["sha256"]) + executable, version = native.java21(Path(java)) + files = [Path(profile), Path(specification), Path(inventory), executable, + *(Path(row["path"]) for row in mods), *oracle_paths()] + return dict(format=1, purpose=("NATIVE61_ENDURANCE_RETROSPECTIVE_SEAL" if retrospective + else "NATIVE61_ENDURANCE_PRE_RUN_SEAL"), + createdAtUtc=datetime.now(timezone.utc).isoformat(), + profile=str(Path(profile).resolve()), specification=str(Path(specification).resolve()), + inventory=str(Path(inventory).resolve()), java=str(executable), javaVersion=version, + files={str(path.resolve()): budget.digest(path) for path in files}, + oracles=[str(path) for path in oracle_paths()]) + + +def verify_seal(path, expected, budget): + pin(budget.read(path), expected) + document = budget.json(path) + require(document.get("format") == 1 and document.get("purpose") in ( + "NATIVE61_ENDURANCE_PRE_RUN_SEAL", "NATIVE61_ENDURANCE_RETROSPECTIVE_SEAL"), "Wrong input seal") + require(document["oracles"] == [str(path) for path in oracle_paths()], "Oracle module coverage changed") + for name, checksum in document["files"].items(): + pin(budget.read(name), checksum) + return document + + +def custody(sealed, run, budget): + setup = min(datetime.fromisoformat(budget.json( + run / mode / "advantage-works-results/W-01-checkpoint-setup.json")["recordedAtUtc"]) + for mode in MODES) + created = datetime.fromisoformat(sealed["createdAtUtc"]) + retrospective = sealed["purpose"] == "NATIVE61_ENDURANCE_RETROSPECTIVE_SEAL" + require(retrospective or created < setup, "Seal was not made before the run; use explicit retrospective mode") + return dict(preRunCustody=not retrospective, sealCreatedAtUtc=sealed["createdAtUtc"], + earliestSetupAtUtc=setup.isoformat(), + coverageGap=("No pre-run oracle custody proof; seal is retrospective" if retrospective else None)) + + +def complete(report): + require(report.get("status") == "PASS" and report.get("minecraft") == native.MINECRAFT + and report.get("forge") == native.FORGE and type(report.get("duration_seconds")) is int + and report["duration_seconds"] == 1800 and type(report.get("interval_seconds")) is int + and report["interval_seconds"] == 60 and report.get("seedUnchanged") is True + and report.get("retained_unchanged") is True and set(report.get("profiles", {})) == set(MODES), + "Only a complete two-profile 30-minute PASS may be frozen/replayed; HELD is not proof") + require(report.get("checked_stations") == endurance.CHECKED and report.get("active_stations") == endurance.ACTIVE, + "Incomplete station coverage") + for mode in MODES: + row = report["profiles"][mode] + require(row.get("status") == row.get("restart") == row.get("resumed_processing") == "PASS" + and type(row.get("endurance_batches")) is int and row["endurance_batches"] == 30 + and type(row.get("verified_checkpoints")) is int and row["verified_checkpoints"] == 33 + and type(row.get("game_ticks")) is int and row["game_ticks"] >= 32400 + and type(row.get("elapsed_seconds")) in (int, float) and math.isfinite(row["elapsed_seconds"]) + and row["elapsed_seconds"] >= 1800, + "Incomplete endurance profile: " + mode) + + +@contextmanager +def stopped(runtime, budget): + text = budget.read(runtime / "console-endurance-restart.log", MAX_JSON).decode("utf-8") + require("Stopping server" in text and "All dimensions are saved" in text, + "Missing final orderly shutdown witnesses") + path = regular(runtime / WORLD / "session.lock") + with path.open("r+b") as stream: + if os.name == "nt": + import msvcrt + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + budget.locked[path.resolve()] = stream + try: + yield + finally: + budget.locked.pop(path.resolve()) + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + budget.locked[path.resolve()] = stream + try: + yield + finally: + budget.locked.pop(path.resolve()) + fcntl.flock(stream, fcntl.LOCK_UN) + + +def freeze(run, seal_path, seal_sha256, receipt_sha256, output, budget): + run, output = Path(run).resolve(), Path(output).resolve() + sealed = verify_seal(seal_path, seal_sha256, budget) + disjoint(output, run, seal_path, *(Path(name) for name in sealed["files"])) + pin(budget.read(run / RECEIPT), receipt_sha256) + report = budget.json(run / RECEIPT) + complete(report) + provenance = custody(sealed, run, budget) + output.mkdir() + result = dict(status="FAIL", format=1, purpose="NATIVE61_STOPPED_ENDURANCE_FREEZE", + sealSha256=seal_sha256.upper(), inputReceiptSha256=receipt_sha256.upper(), source=str(run), + custody=provenance) + try: + with ExitStack() as stack: + for mode in MODES: + stack.enter_context(stopped(run / mode, budget)) + files = tree(run, budget) + for name, checksum in files.items(): + data = budget.read(run / name) + pin(data, checksum) + destination = output / "run" / name + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("xb") as stream: + stream.write(data) + require(tree(run, budget) == files and tree(output / "run", budget) == files, + "Stopped source or frozen copy changed") + verify_seal(seal_path, seal_sha256, budget) + with (output / "seal.json").open("xb") as stream: + stream.write(budget.read(seal_path)) + result.update(status="PASS", files=files) + except BaseException as error: + result["failure"] = repr(error) + raise + finally: + write_new(output / "freeze.json", result) + return result + + +def typed_items(value): + if isinstance(value, nbtlib.Compound): + if "Items" in value: + items = value["Items"] + require(isinstance(items, nbtlib.List) and items.subtype.tag_id in (10, 0), + "Items is not a native compound list") + slots = set() + for item in items: + require(isinstance(item.get("Slot"), nbtlib.Byte) and 0 <= int(item["Slot"]) <= 127 + and int(item["Slot"]) not in slots and isinstance(item.get("count"), nbtlib.Int) + and 1 <= int(item["count"]) <= 64 and isinstance(item.get("id"), nbtlib.String) + and re.fullmatch(r"[a-z0-9_.-]+:[a-z0-9_./-]+", str(item["id"])) + and "Count" not in item and "tag" not in item + and ("components" not in item or isinstance(item["components"], nbtlib.Compound)), + "Wrong native count/Slot/id/components or duplicate slot") + slots.add(int(item["Slot"])) + for child in value.values(): + typed_items(child) + elif isinstance(value, nbtlib.List): + for child in value: + typed_items(child) + + +class UniqueCompound(nbtlib.Compound): + def __setitem__(self, key, value): + if key in self: + raise ValueError("Duplicate SNBT key: " + key) + super().__setitem__(key, value) + + +class UniqueParser(literal_parser.Parser): + parse_compound = bind(literal_parser.Parser.parse_compound, Compound=UniqueCompound) + + +parse_unique_snbt = bind(literal_parser.parse_nbt, Parser=UniqueParser) + + +def row_nbt(row): + require(not ("nbt" in row and "nbtBase64" in row), "Ambiguous checkpoint NBT") + if "nbtBase64" in row: + encoded = row["nbtBase64"] + require(isinstance(encoded, str) and len(encoded) <= MAX_JSON, "Oversized checkpoint NBT") + value = nbtlib.Compound(_nbt(_decompress(base64.b64decode(encoded, validate=True), 1))) + elif "nbt" in row: + require(isinstance(row["nbt"], str) and len(row["nbt"]) <= MAX_JSON, "Oversized checkpoint SNBT") + value = parse_unique_snbt(row["nbt"]) + else: + return None + require(isinstance(value, nbtlib.Compound), "Checkpoint NBT must be a compound") + typed_items(value) + return value + + +def snapshot(document): + rows = document.get("snapshot") + require(isinstance(rows, list) and 0 < len(rows) <= MAX_ROWS, "Missing or excessive checkpoint rows") + seen = set() + for row in rows: + pos = row.get("pos") + require(isinstance(pos, list) and len(pos) == 3 and all(type(n) is int for n in pos) + and tuple(pos) not in seen, "Duplicate or malformed checkpoint coordinate") + seen.add(tuple(pos)) + row_nbt(row) + return native.parse_endurance_snapshot(document) + + +def terrain(payload, coordinates): + require(len(payload) >= 6 and int.from_bytes(payload[:4], "big") == len(payload) - 4, + "Wrong terrain payload length") + root = _nbt(_decompress(payload[5:], payload[4])) + require(isinstance(root.get("DataVersion"), nbtlib.Int) and int(root["DataVersion"]) == DATA_VERSION + and "Level" not in root and isinstance(root.get("xPos"), nbtlib.Int) + and isinstance(root.get("zPos"), nbtlib.Int) + and (int(root["xPos"]), int(root["zPos"])) == coordinates, "Not native DataVersion 4671 terrain") + sections, tiles = {}, {} + require(isinstance(root.get("sections"), nbtlib.List) and len(root["sections"]) <= 256 + and isinstance(root.get("block_entities"), nbtlib.List), "Missing typed native chunk lists") + for section in root["sections"]: + y = section["Y"] + require(isinstance(y, nbtlib.Byte) and int(y) not in sections, "Duplicate or wrongly typed section") + states = section.get("block_states") + if states is None: + sections[int(y)] = None + continue + words = states.get("data", nbtlib.LongArray([])) + require(isinstance(words, nbtlib.LongArray), "Palette data is not native longs") + sections[int(y)] = decode_palette(states["palette"].unpack(), words.tolist()) + for tile in root["block_entities"]: + require(all(isinstance(tile.get(k), nbtlib.Int) for k in ("x", "y", "z")), "Wrong tile coordinates") + pos = tuple(int(tile[k]) for k in ("x", "y", "z")) + require((pos[0] // 16, pos[2] // 16) == coordinates and pos not in tiles, "Duplicate/out-of-chunk tile") + require(isinstance(tile.get("keepPacked"), nbtlib.Byte) and int(tile["keepPacked"]) == 0, + "Not an unpacked saved block entity") + tile = nbtlib.Compound({k: v for k, v in tile.items() if k != "keepPacked"}) + typed_items(tile) + tiles[pos] = tile + return sections, tiles + + +def raw_final(runtime, budget): + root = _nbt(_decompress(budget.read(runtime / WORLD / "level.dat", MAX_JSON), 1)) + version = root["Data"].get("DataVersion") + require(isinstance(version, nbtlib.Int) and int(version) == DATA_VERSION, "Wrong saved level DataVersion") + grouped, count = {}, 0 + for station in endurance.CHECKED: + document = budget.json(runtime / "advantage-works-results" / f"{station}-checkpoint-restart-resumed.json") + snapshot(document) + for row in document["snapshot"]: + x, _, z = row["pos"] + grouped.setdefault((x // 16, z // 16), []).append(row) + count += 1 + regions, differences = {}, [] + for (cx, cz), rows in grouped.items(): + budget.check() + region = cx // 32, cz // 32 + if region not in regions: + data = budget.read(runtime / WORLD / "region" / ("r.%d.%d.mca" % region)) + regions[region] = dict(_payloads(data, *region)) + require((cx, cz) in regions[region], "Missing stopped terrain chunk") + sections, tiles = terrain(regions[region][cx, cz], (cx, cz)) + for row in rows: + x, y, z = pos = tuple(row["pos"]) + palette = sections.get(y // 16) + actual = palette[((y & 15) << 8) | ((z & 15) << 4) | (x & 15)] if palette else {"Name": "minecraft:air"} + actual = dict(Name=actual["Name"], Properties=actual.get("Properties", {})) + if actual != state(row) or typed_nbt_or_none(row_nbt(row)) != typed_nbt_or_none(tiles.get(pos)): + differences.append(dict(pos=list(pos), checkpoint=row, + savedState=actual, savedNbt=typed_nbt_or_none(tiles.get(pos)))) + require(not differences, "Stopped raw terrain/component mismatch: " + json.dumps(differences[:3])) + return dict(status="PASS", dataVersion=DATA_VERSION, comparedRows=count, comparedChunks=len(grouped)) + + +def typed_nbt_or_none(value): + return None if value is None else typed_nbt(value) + + +def feed_audit(runtime, budget): + counts = {} + for boot, expected in zip(("endurance", "endurance-restart"), expected_inventory_acknowledgements(30)): + text = budget.read(runtime / f"console-{boot}.log", MAX_JSON).decode("utf-8") + native.require_clean_log(runtime / f"console-{boot}.log") + require(text.count("Replaced a slot at") == expected, "Missing/extra native inventory acknowledgements") + require(not any(marker in text for marker in ( + "Unknown slot", "Could not replace", "Unknown or incomplete command", "Incorrect argument", + "Unknown item", "There is no container", "Modified block data")), "Failed or NBT-fed input commands") + if boot.endswith("restart"): + require("Advantage Works built" not in text and "Reset 1 station" not in text + and not any("Started " + station + " " in text for station in + ("S-04", "E-03", "E-02", "S-02", "E-01")), "Loaded networks were rebuilt/restarted") + counts[boot] = expected + return dict(status="PASS", inventoryAcknowledgements=counts, + scope="Native feed acknowledgements; exact products/conservation replayed at every batch. " + "Console acknowledgements are not raw pre-processing input snapshots.") + + +def replay(frozen, freeze_sha256, seal_sha256, budget): + frozen = Path(frozen).resolve() + pin(budget.read(frozen / "freeze.json"), freeze_sha256) + manifest = budget.json(frozen / "freeze.json") + require(manifest.get("status") == "PASS" and manifest.get("format") == 1 + and manifest.get("purpose") == "NATIVE61_STOPPED_ENDURANCE_FREEZE" + and manifest.get("sealSha256") == seal_sha256.upper(), "Incomplete or different frozen evidence") + sealed = verify_seal(frozen / "seal.json", seal_sha256, budget) + run = frozen / "run" + require(tree(run, budget) == manifest["files"], "Frozen file inventory changed") + pin(budget.read(run / RECEIPT), manifest["inputReceiptSha256"]) + report = budget.json(run / RECEIPT) + complete(report) + provenance = custody(sealed, run, budget) + require(manifest.get("custody") == provenance, "Frozen custody attestation changed") + require(report["profileSha256"] == sealed["files"][sealed["profile"]] + and report["stationDefinitionSha256"] == sealed["files"][sealed["specification"]] + and report["inventorySha256"] == sealed["files"][sealed["inventory"]] + and report["javaSha256"] == sealed["files"][sealed["java"]] + and report["javaVersion"] == sealed["javaVersion"] + and report["jars"] == {row["id"]: row["sha256"].upper() + for row in budget.json(sealed["profile"])["mods"]}, + "Run differs from pre-run seal") + + class BoundedForge61(Forge61): + parse_snapshot = staticmethod(snapshot) + + @staticmethod + def verify_batch(runtime, label, documents, definitions): + budget.check() + Forge61.verify_batch(runtime, label, documents, definitions) + + replay_checkpoints = bind(checkpoint_audit.replay, read_json=budget.json, digest=budget.digest) + measured = replay_checkpoints(run, Path(sealed["profile"]), Path(sealed["specification"]), adapter=BoundedForge61) + raw, feeds = {}, {} + for mode in MODES: + inventory = budget.json(run / mode / native.INVENTORY) + require(inventory == budget.json(sealed["inventory"]), "Runtime code inventory differs from sealed seed") + require(not list((run / mode).glob("native61-launch-environment-*.json")), "Diagnostic launch is not qualified") + raw[mode], feeds[mode] = raw_final(run / mode, budget), feed_audit(run / mode, budget) + require(tree(run, budget) == manifest["files"], "Frozen files changed during replay") + verify_seal(frozen / "seal.json", seal_sha256, budget) + return dict(status=("PASS" if provenance["preRunCustody"] + else "PASS_RETROSPECTIVE_REPLAY_NOT_PRE_RUN_CUSTODY"), + qualificationAllowed=provenance["preRunCustody"], custody=provenance, + scope="Independent stopped native61 raw and all-batch checkpoint replay; not client acceptance", + inputFreezeSha256=freeze_sha256.upper(), inputSealSha256=seal_sha256.upper(), + inputReceiptSha256=manifest["inputReceiptSha256"], checkpointReplay=measured, + stoppedRaw=raw, nativeFeeds=feeds) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + sealing = commands.add_parser("seal", help="Before run: pin the candidate, Java and imported local oracles") + for name in ("profile", "specification", "inventory", "java"): + sealing.add_argument("--" + name, required=True, type=Path) + for name in ("profile-sha256", "inventory-sha256"): + sealing.add_argument("--" + name, required=True) + sealing.add_argument("--retrospective", action="store_true", help="Never establishes pre-run oracle custody") + freezing = commands.add_parser("freeze", help="After complete PASS and stop: copy and inventory every file") + freezing.add_argument("--run", required=True, type=Path) + freezing.add_argument("--seal", required=True, type=Path) + freezing.add_argument("--receipt-sha256", required=True) + replaying = commands.add_parser("replay", help="Read-only replay of the frozen copy") + replaying.add_argument("--frozen", required=True, type=Path) + replaying.add_argument("--freeze-sha256", required=True) + for command in (freezing, replaying): + command.add_argument("--seal-sha256", required=True) + for command in (sealing, freezing, replaying): + command.add_argument("--output", required=True, type=Path) + command.add_argument("--max-seconds", type=int, default=300) + args = parser.parse_args() + budget = Budget(args.max_seconds) + if args.command == "seal": + result = seal(args.profile, args.profile_sha256, args.specification, args.inventory, + args.inventory_sha256, args.java, budget, retrospective=args.retrospective) + disjoint(args.output, *(Path(name) for name in result["files"])) + write_new(args.output, result) + print(("RETROSPECTIVE_NOT_PRE_RUN " if args.retrospective else "SEALED ") + budget.digest(args.output)) + elif args.command == "freeze": + freeze(args.run, args.seal, args.seal_sha256, args.receipt_sha256, args.output, budget) + print("FROZEN " + budget.digest(args.output / "freeze.json")) + else: + sealed = budget.json(args.frozen / "seal.json") + disjoint(args.output, args.frozen, *(Path(name) for name in sealed["files"])) + result = dict(status="FAIL", inputFreezeSha256=args.freeze_sha256) + try: + result = replay(args.frozen, args.freeze_sha256, args.seal_sha256, budget) + except BaseException as error: + result["failure"] = repr(error) + raise + finally: + write_new(args.output, result) + print(result["status"] + ": frozen native61 endurance replay") + return 0 if result["status"] == "PASS" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_machines.py b/test-rig/migration/native12111_machines.py new file mode 100644 index 00000000..e081d66a --- /dev/null +++ b/test-rig/migration/native12111_machines.py @@ -0,0 +1,139 @@ +"""Run native harvest, lifecycle and cross-mod oil checks without altering the old oracles.""" + +import argparse +from pathlib import Path +import traceback + +import lifecycle +from matrix import digest, fingerprint, write_json +from native117 import output_path, require_disjoint +from native117_actor_io import bind +from native117_identity import fixture_ids +from native117_lifecycle import NativeLifecycleRun, authorize_lifecycle, exercise +from native117_oil import NativeOilRun, exercise as exercise_oil, fixtures as oil_fixtures +from native12111_networks import compatibility_view, native_inventory_item +import native1201_machines +import native12111 as native + + +MINERALOGY = dict(id="mineralogy", version="6.1.4.121111", + sourceRef="d27b800100213c8166a24a7196adf22d05a99bab", + file="Mineralogy-6.1.4.121111.jar", + sha256="2ADE7BEE0ACD5B1463752B3D14A01E27D3FFA5448E9407BA8F41DFAFBCA0D954") + + +def oil_pins(entries): + pins = {"jars": [dict(id=key, version=value[0], path=str(value[1]), sha256=value[2]) + for key, value in entries.items()]} + for pin in pins["jars"]: + if pin["id"] == "mineralogy": + if pin["version"] != MINERALOGY["version"] or pin["sha256"].upper() != MINERALOGY["sha256"]: + raise ValueError("Wrong Mineralogy oil input") + pin["sourceRef"] = MINERALOGY["sourceRef"] + return pins + + +def native_boot(path): + native.require_clean_log(path) + content = path.read_text(encoding="utf-8") + for marker in (f"Starting minecraft server version {native.MINECRAFT}", + f"Forge mod loading, version {native.FORGE}, for MC {native.MINECRAFT} with MCP {native.MCP}"): + if marker not in content: + raise AssertionError("Missing native machine startup proof: " + marker) + + +class LifecycleRun(NativeLifecycleRun): + minecraft = native.MINECRAFT + max_ticket_distance = 11 + require_boot = staticmethod(native_boot) + + def open_server(self, label): + native.audit_staged(self.runtime) + return native.Server(self.runtime, self.java, self.launcher, label) + + def inventory(self, pos, slot, item, count): + coordinates = " ".join(map(str, pos)) + self.command(f"item replace block {coordinates} container.{slot} with {native_inventory_item(item)} {count}") + + def probe(self, operation, label): + return compatibility_view(super().probe(operation, label)) + + +class OilRun(NativeOilRun): + expected_launcher = native.LAUNCHER + max_ticket_distance = 11 + require_boot = staticmethod(native_boot) + + def open_server(self, label): + native.audit_staged(self.runtime) + return native.Server(self.runtime, self.java, self.launcher, label) + + +harvest = bind(native1201_machines.harvest, native1201=native) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("profile", "seed", "inventory", "java", "output"): + parser.add_argument("--" + name, required=True, type=lambda value: Path(value).resolve()) + parser.add_argument("--inventory-sha256", required=True) + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--campaign", choices=("harvest", "oil", "lifecycle"), required=True) + args = parser.parse_args() + target = native.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + java, version = native.java21(args.java) + output = output_path(args.output) + for path in (args.profile, args.seed, args.inventory, java, *(entry[1] for entry in target[2].values())): + require_disjoint(output, path) + seed_before = fingerprint(args.seed) + expected = native.stage(output, *target, args.port) + receipt = output / f"native12111-{args.campaign}-results.json" + report = dict(status="RUNNING", minecraft=native.MINECRAFT, forge=native.FORGE, jars=expected, + profileSha256=digest(args.profile), inventorySha256=digest(args.inventory), + javaSha256=digest(java), javaVersion=version) + + def progress(observations): + report["observations"] = observations + write_json(receipt, report) + + runner = None + try: + if args.campaign == "harvest": + result = harvest(output, java, native.LAUNCHER, {}, progress) + elif args.campaign == "oil": + pins = oil_pins(target[2]) + definition = oil_fixtures(pins, mineralogy_version=MINERALOGY["version"], + mineralogy_source=MINERALOGY["sourceRef"]) + write_json(output / "lifecycle-fixture.json", definition) + authorize_lifecycle(output) + runner = OilRun(output, java, native.LAUNCHER, definition, pins, seed_before) + exercise_oil(runner, 8, progress) + result = dict(report["observations"], laneCount=len(definition), status="PASS") + else: + definition = fixture_ids(lifecycle.fixtures()) + write_json(output / "lifecycle-fixture.json", definition) + authorize_lifecycle(output) + runner = LifecycleRun(output, java, native.LAUNCHER, definition) + result = exercise(runner, "fresh1211", progress) + report.update(status=result["status"], observations=result) + except BaseException as failure: + report.update(status="FAIL", failure=repr(failure), traceback=traceback.format_exc()) + raise + finally: + try: + if runner is not None: + runner.close() + native.verify_runtime(output, target[1], expected) + report["seedUnchanged"] = fingerprint(args.seed) == seed_before + if not report["seedUnchanged"]: + raise AssertionError("Protected server seed changed") + except BaseException as failure: + report.update(status="FAIL", custodyError=repr(failure)) + raise + finally: + write_json(receipt, report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_networks.py b/test-rig/migration/native12111_networks.py new file mode 100644 index 00000000..b5ea567f --- /dev/null +++ b/test-rig/migration/native12111_networks.py @@ -0,0 +1,204 @@ +"""Run physical chunk unload, bridge, drill and interop oracles on Forge 61.""" + +import argparse +from pathlib import Path +import socket +import traceback + +import nbtlib + +from matrix import digest, fingerprint, write_json +from native117 import output_path, require_disjoint +import native117_extended +from native117_geometry import FORCED_LEVEL, FULL_LEVEL +from native117_holder_networks import bridge_fixtures, extended_fixtures, linear_fixtures +from native117_interop import require_checks, require_probe_order +from native117_lifecycle import authorize_lifecycle +from native117_partial import exercise_bridge, exercise_partial +from native117_readiness import await_ready +from native117_ticket_networks import NativeTicketExtendedRun +import native12111 as native + + +# Forge 61's ChunkPyramid has radius 11 (ChunkLevel.MAX_LEVEL = 44). +# Keep the finite fixtures outside that actual loaded-holder apron. +MAX_TICKET_DISTANCE = 11 +HOLDER_RADIUS = FULL_LEVEL + MAX_TICKET_DISTANCE - FORCED_LEVEL + + +def native_inventory_item(item): + identity, separator, payload = item.partition("{") + if not separator: + return item + custom = nbtlib.parse_nbt(separator + payload) + if not isinstance(custom, nbtlib.Compound): + raise AssertionError("Historical drill fixture item data must be a compound") + return identity + "[minecraft:custom_data=" + custom.snbt(compact=True) + "]" + + +def compatibility_view(value): + """Supply the old oracle's accessors while retaining the full native item components.""" + if isinstance(value, list): + return [compatibility_view(item) for item in value] + if not isinstance(value, dict): + return value + result = {key: compatibility_view(item) for key, item in value.items()} + if isinstance(value.get("nbt"), str): + compound = nbtlib.parse_nbt(value["nbt"]) + if not isinstance(compound, nbtlib.Compound): + raise AssertionError("Native lifecycle witness NBT must be a compound") + for stack in compound.get("Items", ()): + if (not isinstance(stack, nbtlib.Compound) or not isinstance(stack.get("id"), nbtlib.String) + or not isinstance(stack.get("count"), nbtlib.Int) or int(stack["count"]) <= 0 + or "Count" in stack or "tag" in stack): + raise AssertionError(f"Expected native 1.21.11 inventory stack: {stack}") + stack["Count"] = stack["count"] + custom = stack.get("components", {}).get("minecraft:custom_data") + if custom is not None: + if not isinstance(custom, nbtlib.Compound): + raise AssertionError("Native inventory custom_data must be a compound") + stack["tag"] = custom + result["nbt"] = compound.snbt(compact=True) + return result + + +class PartialRun(NativeTicketExtendedRun): + minecraft = native.MINECRAFT + max_ticket_distance = MAX_TICKET_DISTANCE + require_boot = staticmethod(native.require_clean_log) + + def open_server(self, label): + return Server(self.runtime, self.java, self.launcher, label) + + def inventory(self, pos, slot, item, count): + coordinates = " ".join(map(str, pos)) + self.command(f"item replace block {coordinates} container.{slot} with {native_inventory_item(item)} {count}") + + def probe(self, operation, label): + return compatibility_view(super().probe(operation, label)) + + +class Server(native.Server): + def __init__(self, runtime, *args): + native.audit_staged(runtime) + super().__init__(runtime, *args) + + def close(self): + try: + super().close() + finally: + native.audit_staged(self.native_runtime) + + +def interop(runtime, java, progress): + observations = {} + for phase in ("exercise", "restored"): + server = Server(runtime, java, native.LAUNCHER, phase) + try: + server.send("forceload add 1023 1023 1100 1030") + ready = await_ready(server, runtime, (63, 63, 68, 64), "interop-" + phase, + minecraft=native.MINECRAFT) + server.send("advinterop " + phase) + server.wait("ADVINTEROP COMPLETE " + phase, 120) + report = native.read_json(runtime / "advantage-works-results" / f"native-interop-{phase}.json") + observations[phase] = dict(observation=report, readiness=ready) + progress(observations) + require_probe_order(report, ready) + require_checks(report, phase) + if "basemetals" in {item["id"] for item in native.read_json(runtime / "network-inputs.json")}: + native.require_shared_crushing(report) + server.send("save-all flush") + server.wait("Saved the game", 30) + finally: + server.close() + return dict(observations=observations, status="PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("profile", "seed", "inventory", "output", "java"): + parser.add_argument("--" + name, type=lambda value: Path(value).resolve(), required=True) + parser.add_argument("--inventory-sha256", required=True) + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--campaign", choices=("partial", "bridge", "extended", "interop"), required=True) + args = parser.parse_args() + if not 26450 <= args.port <= 65535: + parser.error("Use a disposable localhost port at or above 26450") + + runtime = output_path(args.output) + seed, inventory, mods = native.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + java, version = native.java21(args.java) + for path in (args.profile, seed, args.inventory, java, *(item[1] for item in mods.values())): + require_disjoint(runtime, path) + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + protected = fingerprint(seed) + expected = {path.name: sha.upper() for _, path, sha in mods.values()} + helpers = [Path(__file__), *(Path(__file__).with_name(name) for name in ( + "native117_partial.py", "native117_ticket_networks.py", "native117_holder_networks.py", + "native117_extended.py", "extended_lifecycle.py", "lifecycle.py", "audit_pipe_only.py", + ))] + source_hashes = {path.name: digest(path) for path in helpers} + report = dict(status="RUNNING", campaign=args.campaign, qualificationStatus=native.EXPLORATORY, + minecraft=native.MINECRAFT, forge=native.FORGE, port=args.port, + profileSha256=digest(args.profile), inventorySha256=digest(args.inventory), + javaSha256=digest(java), javaVersion=version, jars=expected, oracleSources=source_hashes) + receipt = runtime / f"native12111-{args.campaign}-results.json" + + def progress(value): + report["observations"] = value + write_json(receipt, report) + + runner = None + staged = False + try: + native.stage(runtime, seed, inventory, mods, args.port) + staged = True + write_json(receipt, report) + write_json(runtime / "network-inputs.json", [ + dict(id=key, version=mod_version, file=path.name, sha256=sha.upper()) + for key, (mod_version, path, sha) in mods.items() + ]) + if args.campaign == "interop": + result = interop(runtime, java, progress) + else: + layout, exercise = { + "partial": (linear_fixtures, exercise_partial), + "bridge": (bridge_fixtures, exercise_bridge), + "extended": (extended_fixtures, native117_extended.exercise), + }[args.campaign] + definition = layout(HOLDER_RADIUS) + fixture = runtime / "lifecycle-fixture.json" + write_json(fixture, definition) + report["fixtureSha256"] = digest(fixture) + authorize_lifecycle(runtime) + runner = PartialRun(runtime, java, native.LAUNCHER, definition) + result = exercise(runner, progress) + report.update(status=result["status"], observations=result) + except BaseException as failure: + report.update(status="FAIL", failure=repr(failure), traceback=traceback.format_exc()) + raise + finally: + try: + if runner is not None: + runner.close() + if staged: + native.verify_runtime(runtime, inventory, expected) + report["seedUnchanged"] = fingerprint(seed) == protected + report["oraclesUnchanged"] = all(digest(path) == source_hashes[path.name] for path in helpers) + report["inputsUnchanged"] = all(digest(path) == sha for path, sha in ( + (args.profile, report["profileSha256"]), (args.inventory, report["inventorySha256"]), + (java, report["javaSha256"]), *((path, sha.upper()) for _, path, sha in mods.values()), + )) + if not all(report[key] for key in ("seedUnchanged", "oraclesUnchanged", "inputsUnchanged")): + raise AssertionError("Protected input changed during the native network test") + except Exception as audit: + report.update(status="FAIL", auditError=repr(audit)) + finally: + if runtime.is_dir(): + write_json(receipt, report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_oil_processing.py b/test-rig/migration/native12111_oil_processing.py new file mode 100644 index 00000000..1ce975b1 --- /dev/null +++ b/test-rig/migration/native12111_oil_processing.py @@ -0,0 +1,311 @@ +"""Finite native bucket/pipe/still/boiler processing over two cold restarts. + +Complements, rather than replaces, native12111_machines --campaign oil. +Uses only the frozen harness's public lifecycle commands and vanilla tick gate. +""" + +import argparse +from pathlib import Path +import shutil +import time +import traceback + +import nbtlib + +import lifecycle +from matrix import digest, fingerprint, write_json +from native117 import output_path, require_disjoint +from native117_lifecycle import authorize_lifecycle +from native117_oil import require_document +from native117_readiness import await_ready +import native12111 as native +from native12111_worldgen import MINERALOGY, require + + +def fixtures(): + result = [] + for namespace in ("basepower", "mineralogy"): + for kind in ("still", "boiler"): + x, z = 13312 + 64 * len(result), 2056 + source, drain, pipe = [x + 8, 81, z], [x + 8, 80, z], [x + 9, 80, z] + machine, gate = [x + 10, 80, z], [x + 10, 81, z] + result.append(dict(id=namespace + "-" + kind, min=[x, 72, 2048], max=[x + 31, 88, 2079], + source=source, drain=drain, pipe=pipe, machine=machine, gate=gate, + fluid=namespace + ":crude_oil", bucket=namespace + ":crude_oil_bucket", + kind=kind, block="basepower:still" if kind == "still" else "basesteam:steam_boiler_oil", + witnesses=[source, drain, pipe, machine, gate])) + return result + + +def parse(entry): + return nbtlib.parse_nbt(entry["nbt"]).unpack() + + +def tank(tag, key, fluid): + value = tag[key] # Missing is not an empty tank. + require(isinstance(value, dict), "Malformed tank: " + key) + if value == {"Empty": 1}: + return 0 + require("Empty" not in value and value["FluidName"] == fluid, + "Wrong or ambiguous fluid identity: " + repr(value)) + amount = value["Amount"] + # Still recipes debit FluidStack.amount in place, retaining an explicit zero + # until a load normalizes it. Identity and Amount must both remain present. + require(type(amount) is int and 0 <= amount <= 4000, "Invalid tank amount: " + repr(value)) + return amount + + +def coal_count(tag): + items = tag["Items"] + require(isinstance(items, list) and len(items) <= 1, "Unexpected still inventory") + if not items: + return 0 + item = items[0] + require(item["id"] == "minecraft:coal" and item["Slot"] == 0 and item["count"] == 1, + "Still fuel item changed: " + repr(item)) + return 1 + + +def inspect(snapshot, definition, phase): + require_document(snapshot, definition) + require(snapshot["minecraft"] == native.MINECRAFT and snapshot["forge"] == native.FORGE + and snapshot["station"] == "lifecycle" and type(snapshot["worldTime"]) is int, + "Wrong native processing snapshot") + rows = {} + for f in definition: + entries = {role: lifecycle.at(snapshot, f["id"], f[role]) + for role in ("source", "drain", "pipe", "machine", "gate")} + require(all(e["loaded"] is True for e in entries.values()), "Unloaded processing witness") + gated = phase == "held" or (phase == "intake" and f["kind"] == "still") + expected = dict(source="minecraft:air", drain="basepower:fluid_drain", pipe="basepower:fluid_pipe", + machine=f["block"], gate="minecraft:redstone_block" if gated else "minecraft:air") + require(all(entries[role]["block"] == block for role, block in expected.items()), + "Processing geometry changed: " + f["id"]) + require("nbt" not in entries["pipe"], "Pipe unexpectedly stores fluid") + drain, machine = parse(entries["drain"]), parse(entries["machine"]) + require(tank(drain, "Tank", f["fluid"]) == 0, "Crude oil stuck in drain") + require(all(type(entries[role]["tileIdentity"]) is int for role in ("drain", "machine")), + "Missing live block entity witness") + if f["kind"] == "still": + raw, product = tank(machine, "InputTank", f["fluid"]), tank(machine, "Tank", "basepower:refined_oil") + require(raw + 2 * product == 1000, "Still violated exact 2:1 crude/product conservation") + fuel = coal_count(machine) + if phase in ("held", "intake"): + require((raw, product, fuel, machine["burnTime"]) == (1000, 0, 1, 0), + "Redstone held still processed/spent finite resources") + else: + require(fuel == 0 and machine["totalBurnTime"] > 0, "Still did not consume its one real coal") + require(0 < product < 500 if phase == "partial" else (raw, product) == (0, 500), + "Still failed finite processing checkpoint") + rows[f["id"]] = dict(raw=raw, product=product, coal=fuel, burnTime=machine["burnTime"]) + else: + raw, water = tank(machine, "FuelTank", f["fluid"]), tank(machine, "WaterTank", "minecraft:water") + steam = entries["machine"]["energy"]["steam"] + require(type(steam) in (int, float) and 0 <= steam <= 80, "Unexpected steam production") + if phase in ("held", "intake"): + require((raw, water, steam) == (1000, 64 if phase == "held" else 0, 0) and "BurnTime" not in machine + and "BurnTimeTotal" not in machine, "Held boiler spent fuel/water or produced energy") + burn = 0 + else: + require((raw, water, machine["BurnTimeTotal"]) == (900, 0, 500), + "Boiler must debit exactly one 100-mB crude aliquot and 64-mB water") + if phase == "partial": + burn = machine["BurnTime"] + require(type(burn) is int and 0 < burn < 500 and steam == 64 * 1.25, + "Both crude providers must produce exactly 80 steam before idle decay") + else: + require("BurnTime" not in machine, "Boiler has not completed its finite charge") + burn = 0 + rows[f["id"]] = dict(raw=raw, water=water, steam=steam, burnTime=burn) + return rows + + +def require_decay(before, after, elapsed, *, exact): + require(type(elapsed) is int and elapsed >= 0, "Processing clock went backwards") + for key, old in before.items(): + if key.endswith("-boiler"): + new = after[key] + idle = max(0, elapsed - old["burnTime"]) + expected = max(0, old["steam"] - idle * 0.0625) + # Cold chunk loading may skip idle ticks, never create energy. + require(abs(new["steam"] - expected) < 0.0001 if exact else expected <= new["steam"] <= old["steam"], + "Steam was created/lost beyond documented idle decay: " + key) + + +def require_same_machines(before, after, definition): + for f in definition: + for role in ("drain", "machine"): + old, new = (lifecycle.at(snapshot, f["id"], f[role]) for snapshot in (before, after)) + require(old["tileIdentity"] == new["tileIdentity"], "Processing replaced a live machine") + + +class ProcessingRun(lifecycle.LifecycleRun): + def __init__(self, *args): + super().__init__(*args) + self.pids = [] + + def boot(self, label): + native.audit_staged(self.runtime) + self.server = native.Server(self.runtime, self.java, self.launcher, label) + self.pids.append(self.server.process.pid) + require(len(set(self.pids)) == len(self.pids), "Cold restart reused a process") + self.server.send("gamerule doMobSpawning false") + self.probe("load", label + "-loaded") + for f in self.definition: + bounds = [f["min"][0] >> 4, f["min"][2] >> 4, f["max"][0] >> 4, f["max"][2] >> 4] + await_ready(self.server, self.runtime, bounds, label + "-" + f["id"], minecraft=native.MINECRAFT) + self.command("tick freeze") + self.server.wait("The game is frozen", 15) + + def step(self, ticks, label): + start = self.probe("snapshot", label + "-before")["worldTime"] + self.command("tick step " + str(ticks)) + deadline = time.monotonic() + ticks / 10 + 30 + attempt = 0 + while time.monotonic() < deadline: + snapshot = self.probe("snapshot", f"{label}-step-{attempt:03}") + elapsed = snapshot["worldTime"] - start + require(0 <= elapsed <= ticks, "Vanilla frozen step exceeded requested ticks") + if elapsed == ticks: + return snapshot + attempt += 1 + time.sleep(0.5) + raise AssertionError("Vanilla finite tick step timed out") + + def build(self): + for f in self.definition: + self.command("fill " + " ".join(map(str, f["min"] + f["max"])) + " minecraft:air replace") + for role, block in (("gate", "minecraft:redstone_block" if f["kind"] == "still" else "minecraft:air"), + ("drain", "basepower:fluid_drain"), + ("pipe", "basepower:fluid_pipe"), ("machine", f["block"])): + self.block(f[role], block) + x, y, z = f["source"] + for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1)): + self.block([x + dx, y, z + dz], "minecraft:glass") + pos = " ".join(map(str, f["machine"])) + if f["kind"] == "still": + self.command(f"item replace block {pos} container.0 with minecraft:coal 1") + empty = self.probe("snapshot", "processing-empty") + require_document(empty, self.definition) + for f in self.definition: + tag = parse(lifecycle.at(empty, f["id"], f["machine"])) + require(tank(tag, "InputTank" if f["kind"] == "still" else "FuelTank", f["fluid"]) == 0, + "Processing fixture contains preexisting oil") + # This is the only raw-oil injection, through actual native BucketItem use. + self.probe("place-buckets", "processing-buckets") + + def charge_water_and_hold(self): + # Redstone blocks native boiler pipe requests as well as new burns. + # Load its finite crude without water first, then close the gate. + for f in self.definition: + if f["kind"] == "boiler": + self.block(f["gate"], "minecraft:redstone_block") + pos = " ".join(map(str, f["machine"])) + self.command(f'data modify block {pos} WaterTank set value {{FluidName:"minecraft:water",Amount:64}}') + + def save_close(self): + self.command("save-all flush") + self.server.wait("Saved the game", 30) + self.close() + + +def run(args): + require(1024 <= args.port <= 65535, "Use an unprivileged assigned port") + native.validate_hash(args.profile, args.profile_sha256) + target = native.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + mineralogy = target[2]["mineralogy"] + require(mineralogy[0] == MINERALOGY["version"] and mineralogy[2].upper() == MINERALOGY["sha256"], + "Processing requires the approved separately frozen public Mineralogy jar") + java, version = native.java21(args.java) + runtime = output_path(args.output) + sources = [args.profile, args.inventory, java, Path(__file__).resolve(), *(v[1] for v in target[2].values())] + for source in [args.seed, *sources]: + require_disjoint(runtime, source) + hashes = {str(path): digest(path) for path in sources} + seed_before = fingerprint(args.seed) + expected = native.stage(runtime, *target, args.port) + shutil.copy2(Path(__file__), runtime / "adapter-source.py") + definition = fixtures() + write_json(runtime / "lifecycle-fixture.json", definition) + authorize_lifecycle(runtime) + runner = ProcessingRun(runtime, java, native.LAUNCHER, definition) + receipt = runtime / "native12111-oil-processing-results.json" + report = dict(status="RUNNING", qualificationStatus=native.EXPLORATORY, minecraft=native.MINECRAFT, + forge=native.FORGE, inputs=hashes, jars=expected, javaVersion=version, + mineralogySourceRef=MINERALOGY["sourceRef"], phases={}, fullProcessRestarts=0) + + def record(label, snapshot, phase): + rows = inspect(snapshot, definition, phase) + report["phases"][label] = dict(status="PASS", worldTime=snapshot["worldTime"], cases=rows) + write_json(receipt, report) + print(label + ": PASS " + str(rows), flush=True) + return rows + + failed = False + try: + write_json(receipt, report) + runner.boot("processing-initial") + runner.build() + intake = runner.step(160, "processing-intake") + record("intake-no-water", intake, "intake") + runner.charge_water_and_hold() + first = runner.step(80, "processing-held") + record("held", first, "held") + require_same_machines(intake, first, definition) + runner.save_close() + + runner.boot("processing-restart") + report["fullProcessRestarts"] = 1 + restored = runner.probe("snapshot", "processing-restored-inputs") + record("restored-inputs", restored, "held") + for f in definition: + runner.block(f["gate"], "minecraft:air") + partial = runner.step(160, "processing-partial") + early = record("partial", partial, "partial") + completed = runner.step(440, "processing-complete") + final = record("complete", completed, "complete") + require_same_machines(restored, partial, definition) + require_same_machines(partial, completed, definition) + require_decay(early, final, completed["worldTime"] - partial["worldTime"], exact=True) + runner.save_close() + + runner.boot("processing-second-restart") + report["fullProcessRestarts"] = 2 + retained = runner.probe("snapshot", "processing-retained-products") + later = record("retained-products", retained, "complete") + require_decay(final, later, retained["worldTime"] - completed["worldTime"], exact=False) + runner.save_close() + report.update(status="PASS", processIds=runner.pids) + except BaseException as error: + failed = True + report.update(status="FAIL", failure=repr(error), traceback=traceback.format_exc()) + raise + finally: + try: + runner.close() + native.verify_runtime(runtime, target[1], expected) + report["seedUnchanged"] = fingerprint(args.seed) == seed_before + require(report["seedUnchanged"], "Protected Forge seed changed") + require(all(digest(Path(path)) == sha for path, sha in hashes.items()), "Frozen input changed") + except BaseException as error: + report.update(status="FAIL", custodyError=repr(error)) + if not failed: + raise + finally: + write_json(receipt, report) + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("profile", "seed", "inventory", "java", "output"): + parser.add_argument("--" + name, required=True, type=lambda value: Path(value).resolve()) + for name in ("profile-sha256", "inventory-sha256"): + parser.add_argument("--" + name, required=True) + parser.add_argument("--port", required=True, type=int) + print(run(parser.parse_args())["status"], flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_operations.py b/test-rig/migration/native12111_operations.py new file mode 100644 index 00000000..44a19db2 --- /dev/null +++ b/test-rig/migration/native12111_operations.py @@ -0,0 +1,169 @@ +"""Measure complete finite machine jobs, then compare their stopped saves and cold reload.""" + +import argparse +import copy +import gzip +import io +import math +from pathlib import Path +import traceback +import zlib + +import nbtlib + +from matrix import digest, fingerprint, write_json +from native117 import output_path, require_disjoint +from native117_actor_io import bind +from native117_machine_operations import validate_operation, validate_restart as frozen_validate_restart +import native117_machine_saved as saved +import native12111 as native +from native12111_actor_comparison import CASES as EXPLOSIVE_ACTOR_CASES, actors as native_actors + + +def modern_chunk(payload): + if len(payload) < 6 or int.from_bytes(payload[:4], "big") != len(payload) - 4: + raise ValueError("Invalid native terrain payload length") + decoders = {1: gzip.decompress, 2: zlib.decompress, 3: lambda value: value} + root = nbtlib.File.parse(io.BytesIO(decoders[payload[4]](payload[5:]))) + if int(root.get("DataVersion", -1)) != 4671 or "Level" in root: + raise ValueError("Machine save must be native 1.21.11 terrain") + sections = [] + for section in root["sections"]: + view = nbtlib.Compound({"Y": section["Y"]}) + if "block_states" in section: + states = section["block_states"] + view["Palette"] = states["palette"] + if "data" in states: + view["BlockStates"] = states["data"] + sections.append(view) + # Supply only field-name accessors to the unchanged, typed raw-save oracle. + return nbtlib.Compound({"xPos": root["xPos"], "zPos": root["zPos"], + "Sections": nbtlib.List[nbtlib.Compound](sections), + "TileEntities": root["block_entities"]}) + + +audit_saved = bind(saved.audit, chunk=modern_chunk) + + +def validate_restart(fixture, inspected): + expected = copy.deepcopy(fixture) + transitions = [] + for case in expected.get("cases", []): + if case["id"] not in EXPLOSIVE_ACTOR_CASES: + continue + tags = native_actors(case) + for actor in case.get("entities", []): + tag = tags[actor["uuid"]] + if (type(tag.get("id")) is not nbtlib.String or str(tag["id"]) != "minecraft:cow" + or type(tag.get("NoAI")) is not nbtlib.Byte or int(tag["NoAI"]) != 1 + or type(tag.get("NoGravity")) is not nbtlib.Byte or int(tag["NoGravity"]) != 1): + continue + motion = tag.get("Motion") + if (not isinstance(motion, nbtlib.List) or motion.subtype is not nbtlib.Double + or len(motion) != 3 or any(not math.isfinite(float(value)) for value in motion)): + raise AssertionError("Malformed native explosive actor Motion") + changes = [] + # Pinned 1.21.11 Entity.load clamps each outlier, not the whole vector. + for index, value in enumerate(motion): + if abs(float(value)) > 10.0: + changes.append(dict(field=f"Motion[{index}]", before=float(value), expected=0.0)) + motion[index] = nbtlib.Double(0.0) + # LivingEntity's later relative-key assignment overwrites the legacy absolute timestamp. + # This exception is proven only for absent attacker/relative keys and an unticked reload. + if ("last_hurt_by_mob" not in tag and "ticks_since_last_hurt_by_mob" not in tag + and "HurtByTimestamp" in tag): + timestamp = tag["HurtByTimestamp"] + if type(timestamp) is not nbtlib.Int or int(timestamp) < 0: + raise AssertionError("Malformed native explosive actor HurtByTimestamp") + if int(timestamp) != 0: + changes.append(dict(field="HurtByTimestamp", before=int(timestamp), expected=0)) + tag["HurtByTimestamp"] = nbtlib.Int(0) + if changes: + actor["nbt"] = tag.snbt() + transitions.append(dict(case=case["id"], uuid=actor["uuid"], fields=changes)) + for case in inspected.get("cases", []): + if case["id"] in EXPLOSIVE_ACTOR_CASES: + native_actors(case) + result = frozen_validate_restart(expected, inspected) + result["expectedBeforeLoadTransformations"] = transitions + result["comparison"] += ( + "; 1.21.11 explosive root-cow expected-only native Motion >10 clamp and absent-relative-key hurt timestamp rebase; " + "observed full NBT is unchanged") + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("profile", "seed", "inventory", "java", "output"): + parser.add_argument("--" + name, required=True, type=lambda value: Path(value).resolve()) + parser.add_argument("--inventory-sha256", required=True) + parser.add_argument("--port", required=True, type=int) + parser.add_argument("--group", choices=("all", "pickup", "power", "steam", "electric"), default="all") + args = parser.parse_args() + target = native.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + java, version = native.java21(args.java) + runtime = output_path(args.output) + for path in (args.profile, args.seed, args.inventory, java, *(entry[1] for entry in target[2].values())): + require_disjoint(runtime, path) + seed_before = fingerprint(args.seed) + expected = native.stage(runtime, *target, args.port) + for marker in ("advantage-machine-operation.allow", "migration-operate.allow"): + (runtime / marker).touch() + report = dict(status="RUNNING", minecraft=native.MINECRAFT, forge=native.FORGE, group=args.group, + profileSha256=digest(args.profile), inventorySha256=digest(args.inventory), jars=expected, + javaSha256=digest(java), javaVersion=version, phases={}) + receipt = runtime / "native12111-machine-operation-results.json" + write_json(receipt, report) + server = None + try: + server = native.Server(runtime, java, native.LAUNCHER, "operate") + for command in ("gamerule doMobSpawning false", "gamerule randomTickSpeed 0", + "gamerule doWeatherCycle false", "weather clear"): + server.send(command) + server.send("advmachines run " + args.group + " measured") + server.wait("ADVMACHINES COMPLETE measured", 1500) + catalog = native.read_json(runtime / "advantage-works-results/machine-catalog-measured.json") + document = native.read_json(runtime / "advantage-works-results/machine-operation-measured.json") + report["phases"]["operation"] = validate_operation(catalog, document, args.group) + server.send("save-all flush") + server.wait("Saved the game", 30) + server.close() + server = None + fixture = native.read_json(runtime / "machine-operation-fixture.json") + fixture_sha = report["fixtureSha256"] = digest(runtime / "machine-operation-fixture.json") + report["phases"]["saved-before-restart"] = audit_saved(runtime, fixture, runtime / "raw-before-restart") + write_json(receipt, report) + server = native.Server(runtime, java, native.LAUNCHER, "restart") + server.send("advmachines inspect restarted") + server.wait("ADVMACHINES INSPECTED restarted", 360) + observed = native.read_json(runtime / "advantage-works-results/machine-inspection-restarted.json") + report["phases"]["restart"] = validate_restart(fixture, observed) + server.send("save-all flush") + server.wait("Saved the game", 30) + server.close() + server = None + report["phases"]["saved-after-restart"] = audit_saved(runtime, observed, runtime / "raw-after-restart") + if digest(runtime / "machine-operation-fixture.json") != fixture_sha: + raise AssertionError("Operating fixture changed across restart") + report["status"] = "PASS" if all(value["status"] == "PASS" for value in report["phases"].values()) else "FAIL" + except BaseException as failure: + report.update(status="FAIL", failure=repr(failure), traceback=traceback.format_exc()) + raise + finally: + try: + if server is not None: + server.close() + native.verify_runtime(runtime, target[1], expected) + report["seedUnchanged"] = fingerprint(args.seed) == seed_before + if not report["seedUnchanged"]: + raise AssertionError("Protected server seed changed") + except BaseException as failure: + report.update(status="FAIL", custodyError=repr(failure)) + raise + finally: + write_json(receipt, report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_order_probe.py b/test-rig/migration/native12111_order_probe.py new file mode 100644 index 00000000..3f1b3c96 --- /dev/null +++ b/test-rig/migration/native12111_order_probe.py @@ -0,0 +1,232 @@ +"""Local instrumented Mixin-order proof, never normal production qualification.""" + +import argparse +import copy +import json +import os +from pathlib import Path +import shutil +import subprocess +from types import SimpleNamespace +import traceback + +from matrix import digest, fingerprint, write_json +from native117 import output_path, require_disjoint +from native117_actor_io import bind +import native12111 as native +import native12111_upgrade as upgrade + +SCOPE = "INSTRUMENTED_ORDER_TEST_ONLY" +MOD_ID = "advantageorderprobe" +VERSION = "1.0.0.121111" +MOD_IDS = {"basepower", "basemetals", "basesteam", "baseelectric", "orespawn", "advantageworkstest"} +MODES = ("power-low", "metals-low") +VECTORS = ("regionHeads", "regionReturns", "worldHeads", "worldgenHeads") + + +def variant_profile(base, sidecar, output): + result = copy.deepcopy(base) + if len(result["mods"]) != 6 or {row["id"] for row in result["mods"]} != MOD_IDS: + raise AssertionError("Order test requires exactly six pinned production inputs") + path = Path(sidecar["jar"]).resolve(strict=True) + native.validate_hash(path, sidecar["sha256"]) + result["mods"].append(dict(id=MOD_ID, version=VERSION, file=path.name, path=str(path), sha256=sidecar["sha256"])) + result["orderProbe"] = dict(scope=SCOPE, qualificationAllowed=False) + write_json(output, result) + return result + + +def verify_plugin(document, pins, runtime, pid): + if (document.get("status") != SCOPE or document.get("qualificationAllowed") is not False + or document.get("mode") != pins["mode"] or document.get("runtime") != str(runtime.resolve()) + or document.get("pid") != pid or document.get("preparedBefore") is not False): + raise AssertionError("Plugin proof does not bind this instrumented boot") + before, after = document["before"], document["after"] + if set(before) != set(after) or not set(pins["priorities"]) <= set(before): + raise AssertionError("Registered config set changed") + for name, value in before.items(): + expected = pins["priorities"].get(name, value) + if name in pins["priorities"] and value != 1000: + raise AssertionError("Child priority was already modified") + if after[name] != expected: + raise AssertionError("Unexpected/missing priority mutation") + + +def compare_orders(first, second): + def pair(value): + return isinstance(value, list) and len(value) == 2 and set(value) == {"basepower", "basemetals"} + + for record in (first, second): + if (record.get("status") != "PASS" or record.get("scope") != SCOPE + or record.get("qualificationAllowed") is not False + or record.get("allHandlerBodiesMatchPinnedProduction") is not True + or record.get("nativeReturnsCovered") != 2 or record.get("exceptionEdgesChecked") is not True): + raise AssertionError("Missing strict woven-bytecode proof") + for name in VECTORS: + left, right = first[name], second[name] + if name == "regionReturns": + if len(left) != 2 or len(right) != 2: + raise AssertionError("Native return paths missing") + pairs = zip(left, right) + else: + pairs = [(left, right)] + for before, after in pairs: + if not pair(before) or not pair(after) or after != list(reversed(before)): + raise AssertionError("Actual emitted order did not reverse: " + name) + return dict(status="PASS", scope=SCOPE, qualificationAllowed=False, + permutations={name: [first[name], second[name]] for name in VECTORS}) + + +def diagnostic_manifest(runtime, label): + path = runtime / ("order-export-" + label + ".json") + document = dict(format=1, purpose="NATIVE61_MIXIN_EXPORT_DIAGNOSTIC", runtime=str(runtime.resolve()), label=label, + properties={"mixin.debug.export": True, "mixin.debug.export.decompile": False}) + with path.open("x", encoding="utf-8") as output: + json.dump(document, output, sort_keys=True, indent=2) + output.write("\n") + return path, digest(path) + + +def audit_command(java, build, variant, runtime, exports, output): + classpath = os.pathsep.join([variant["jar"], *build["classpath"]]) + result = subprocess.run([str(java), "-cp", classpath, "zone.moddev.orderprobe.OrderProbeAudit", "audit", + variant["pins"], str(runtime), str(exports), build["nativeClasses"], str(output)], + env=native.native_launch_environment(), capture_output=True, text=True, timeout=60) + output.with_suffix(".log").write_text(result.stdout + result.stderr, encoding="utf-8") + if result.returncode: + raise AssertionError("Woven bytecode audit failed; retained " + str(output.with_suffix(".log"))) + proof = native.read_json(output) + if proof.get("scope") != SCOPE or proof.get("qualificationAllowed") is not False: + raise AssertionError("Audit proof qualification boundary changed") + return proof + + +def run(args): + output = output_path(args.output) + native.validate_hash(args.build_receipt, args.build_receipt_sha256) + build = native.read_json(args.build_receipt) + base = native.read_json(args.profile) + if build.get("scope") != SCOPE or build.get("qualificationAllowed") is not False or build["profileSha256"] != digest(args.profile): + raise AssertionError("Sidecar build is not bound to this exact production profile") + target = native.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + java, _ = native.java21(args.java) + if digest(java) != build["javaSha256"]: + raise AssertionError("Java executable differs from sidecar build") + for path, sha in build["classpath"].items(): + native.validate_hash(Path(path), sha) + native_classes = Path(build["nativeClasses"]) + if fingerprint(native_classes) != build["nativeClassHashes"]: + raise AssertionError("Native audit reference bytes changed") + source_root = Path(__file__).resolve().parents[1] / "order-probe" + def compiled_sources(values): + return {key: value for key, value in values.items() + if key.replace("\\", "/").startswith(("src/", "resources/")) or key == "build.py"} + if compiled_sources(fingerprint(source_root)) != compiled_sources(build["sourceFingerprint"]): + raise AssertionError("Compiled sidecar source inputs changed since the pinned build") + for protected in (args.profile, args.seed, args.inventory, args.source, args.legacy_reference, + args.build_receipt, *[value[1] for value in target[2].values()]): + require_disjoint(output, protected) + for mode in MODES: + for key, checksum in (("jar", "sha256"), ("pins", "pinsSha256")): + native.validate_hash(Path(build["variants"][mode][key]), build["variants"][mode][checksum]) + output.mkdir(parents=True) + source_before, seed_before = fingerprint(args.source), fingerprint(args.seed) + reference_before = fingerprint(args.legacy_reference) + protected_inputs = {args.profile: digest(args.profile), args.build_receipt: digest(args.build_receipt), + args.inventory: digest(args.inventory), java: digest(java), Path(__file__): digest(Path(__file__)), + Path(native.__file__): digest(Path(native.__file__)), Path(upgrade.__file__): digest(Path(upgrade.__file__))} + protected_inputs.update({Path(path): checksum for path, checksum in build["classpath"].items()}) + protected_inputs.update({native_classes / path: checksum for path, checksum in build["nativeClassHashes"].items()}) + for variant in build["variants"].values(): + protected_inputs[Path(variant["jar"])] = variant["sha256"] + protected_inputs[Path(variant["pins"])] = variant["pinsSha256"] + shutil.copy2(Path(__file__), output / "runner-source.py") + result = dict(status="RUNNING", scope=SCOPE, qualificationAllowed=False, profileSha256=digest(args.profile), + buildReceiptSha256=digest(args.build_receipt), runnerSha256=digest(Path(__file__)), modes={}) + report = output / "native12111-order-probe-results.json" + write_json(report, result) + try: + for index, mode in enumerate(MODES): + variant = build["variants"][mode] + pins = native.read_json(Path(variant["pins"])) + profile = output / (mode + "-profile.json") + variant_profile(base, variant, profile) + observations = [] + + class OrderServer(native.Server): + def __init__(self, runtime, java, launcher, label): + self.order_archived = False + manifest, checksum = diagnostic_manifest(Path(runtime), label) + super().__init__(runtime, java, launcher, label, diagnostic_environment=manifest, + diagnostic_environment_sha256=checksum) + + def close(self): + try: + super().close() + finally: + if not self.order_archived: + self.order_archived = True + runtime = self.native_runtime + folder = runtime / "order-evidence" / self.native_label + folder.mkdir(parents=True, exist_ok=False) + plugin_file = runtime / ("native61-order-plugin-" + str(self.process.pid) + ".json") + plugin = native.read_json(plugin_file) + verify_plugin(plugin, pins, runtime, self.process.pid) + if plugin["pinsSha256"] != variant["pinsSha256"]: + raise AssertionError("Plugin resource pins changed") + shutil.copy2(plugin_file, folder / "plugin.json") + shutil.copytree(runtime / ".mixin.out/class", folder / "export") + woven = audit_command(java, build, variant, runtime, folder / "export", folder / "audit.json") + launch = native.read_json(runtime / ("native61-launch-environment-" + self.native_label + ".json")) + if launch["status"] != "DIAGNOSTIC_NOT_QUALIFIED" or launch["qualificationAllowed"] is not False: + raise AssertionError("Diagnostic launch was relabelled as production qualified") + observations.append(dict(runtime=str(runtime), label=self.native_label, audit=woven, + auditSha256=digest(folder / "audit.json"), pluginSha256=digest(plugin_file))) + + with_server = bind(upgrade.with_server, Server=OrderServer) + operate = bind(upgrade.operate, with_server=with_server) + campaign = bind(upgrade.run, with_server=with_server, operate=operate) + values = SimpleNamespace(**vars(args)) + values.profile, values.output, values.port = profile, output / mode / "original110", args.port + index + code = campaign(values) + if code or len(observations) != 3: + raise AssertionError("Original110 conversion/restart/operation did not complete with three woven proofs") + signature = {key: observations[0]["audit"][key] for key in VECTORS} + if any({key: observation["audit"][key] for key in VECTORS} != signature for observation in observations): + raise AssertionError("Actual order changed between cold boots") + result["modes"][mode] = dict(status="PASS", observations=observations) + write_json(report, result) + result["permutationProof"] = compare_orders(result["modes"][MODES[0]]["observations"][0]["audit"], + result["modes"][MODES[1]]["observations"][0]["audit"]) + result["status"] = "PASS" + except BaseException as failure: + result.update(status="FAIL", error=repr(failure), traceback=traceback.format_exc()) + raise + finally: + result["sourceUnchanged"] = fingerprint(args.source) == source_before + result["seedUnchanged"] = fingerprint(args.seed) == seed_before + result["productionJarsUnchanged"] = all(digest(path) == sha.upper() for _, path, sha in target[2].values()) + result["referenceUnchanged"] = fingerprint(args.legacy_reference) == reference_before + result["inputsUnchanged"] = all(digest(path) == checksum.upper() for path, checksum in protected_inputs.items()) + result["inputHashes"] = {str(path): checksum for path, checksum in protected_inputs.items()} + if not all(result[key] for key in ("sourceUnchanged", "seedUnchanged", "productionJarsUnchanged", "referenceUnchanged", "inputsUnchanged")): + result["status"] = "FAIL" + write_json(report, result) + return int(result["status"] != "PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("profile", "seed", "inventory", "java", "output", "source", "legacy-reference", "build-receipt"): + parser.add_argument("--" + name, type=lambda value: Path(value).resolve(), required=True) + for name in ("inventory-sha256", "receipt-sha256", "build-receipt-sha256"): + parser.add_argument("--" + name, required=True) + parser.add_argument("--port", type=int, required=True) + args = parser.parse_args() + if not 26520 <= args.port <= 26528: + parser.error("Use two assigned consecutive instrumented ports within 26520..26529") + return run(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_recipes.py b/test-rig/migration/native12111_recipes.py new file mode 100644 index 00000000..190a181f --- /dev/null +++ b/test-rig/migration/native12111_recipes.py @@ -0,0 +1,93 @@ +"""Exercise live recipe discovery and progression against pinned Forge 61 player jars.""" + +import argparse +from pathlib import Path + +from matrix import digest, fingerprint, write_json +from native117 import output_path, require_disjoint +from native117_recipes import ( + MOD_COUNTS, RecipePruning, progression_campaign, recipe_fixture, require_free_port, unlock_campaign, +) +import native12111 + + +class RecipeServer(RecipePruning, native12111.Server): + @staticmethod + def errors(text): + return native12111.log_findings(text)[0] + + +class Engine: + version = (native12111.MINECRAFT, native12111.FORGE) + pack_metadata = {"min_format": [94, 1], "max_format": [94, 1], + "description": "Disposable loot-override witness"} + require_clean_log = staticmethod(native12111.require_clean_log) + loot_directory = "loot_table" + server = native12111.Server + recipe_server = RecipeServer + result_name = "native12111-recipes-results.json" + + @staticmethod + def stage(runtime, target, port, campaign): + native12111.stage(runtime, *target, port) + (runtime / ("advantage-recipes.allow" if campaign == "unlock" else "advantage-progression.allow")).touch() + if campaign == "progression": + (runtime / "advantage-works.allow").touch() + (runtime / "migration-operate.allow").touch() + return native12111.LAUNCHER + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("profile", "seed", "inventory", "output", "java"): + parser.add_argument("--" + name, required=True, type=Path) + parser.add_argument("--inventory-sha256", required=True) + parser.add_argument("--port", required=True, type=int) + parser.add_argument("--campaign", choices=("all", "unlock", "progression"), default="all") + args = parser.parse_args() + target = native12111.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + seed, inventory, mods = target + if set(mods) != {"basepower", "basesteam", "baseelectric", "advantageworkstest", "basemetals", "orespawn"}: + raise ValueError("The recipe and processing campaign needs the complete Base Metals profile") + require_free_port(args.port) + root = output_path(args.output) + for path in (args.profile, seed, args.inventory, *(path for _, path, _ in mods.values())): + require_disjoint(root, path) + definition, sources = recipe_fixture([path for _, path, _ in mods.values()], + component_predicates=True, singular_directories=True) + entries = {"jars": [ + dict(id=mod, version=version, path=str(path), sha256=sha.upper()) + for mod, (version, path, sha) in mods.items() + ]} + expected = {path.name: sha.upper() for _, path, sha in mods.values()} + before = fingerprint(seed) + report = dict(status="RUNNING", minecraft=native12111.MINECRAFT, forge=native12111.FORGE, + campaign=args.campaign, profileSha256=digest(args.profile), inventorySha256=digest(args.inventory), + javaSha256=digest(args.java), target_jars=entries["jars"], recipe_inventory=MOD_COUNTS, + unlock_boots=[], progression_cases=[]) + write_json(root / "packaged-recipe-sources.json", sources) + try: + if args.campaign in ("all", "unlock"): + unlock_campaign(root, target, entries, args.java, args.port, definition, report, engine=Engine) + if len(report["unlock_boots"]) != 5: + raise AssertionError("Incomplete Forge 61 unlock campaign") + native12111.verify_runtime(root / "unlock", inventory, expected) + if args.campaign in ("all", "progression"): + progression_campaign(root, target, entries, args.java, args.port, report, engine=Engine) + if len(report["progression_cases"]) != 18: + raise AssertionError("Incomplete Forge 61 progression campaign") + native12111.verify_runtime(root / "progression", inventory, expected) + report["status"] = "PASS" + except BaseException as failure: + report.update(status="FAIL", error=repr(failure)) + raise + finally: + report["seed_unchanged"] = before == fingerprint(seed) + if not report["seed_unchanged"]: + report["status"] = "FAIL" + write_json(root / Engine.result_name, report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_stopped_actor_reference.py b/test-rig/migration/native12111_stopped_actor_reference.py new file mode 100644 index 00000000..3f3b4680 --- /dev/null +++ b/test-rig/migration/native12111_stopped_actor_reference.py @@ -0,0 +1,162 @@ +"""Seal an actor reference from stopped source disk only, before any target load.""" + +import argparse +import copy +import io +from pathlib import Path +import uuid + +import nbtlib +import actors +from matrix import WORLD, digest, fingerprint, plain, write_json +from native117 import require_disjoint +import native117_entity_storage as storage +from native117_format import uuid_longs +import native12111 as native + +VERSION = 3955 +SCOPE = "SOURCE_STOPPED_DISK_ONLY_NATIVE1211_ACTOR_REFERENCE" +WITNESSES = {"cart": "minecraft:chest_minecart", "dropped-musket": "minecraft:item", + "dropped-oil": "minecraft:item", "owned-wolf": "minecraft:wolf", "hydroturbine": "baseelectric:hydroturbine"} + + +def read_nbt(path): + return storage._nbt(storage._decompress(path.read_bytes(), 1)) + + +def identity(words): + if not isinstance(words, nbtlib.IntArray): + raise AssertionError("Source player requires a native UUID int array") + most, least = uuid_longs(list(map(int, words))) + return str(uuid.UUID(int=((most & ((1 << 64) - 1)) << 64) | (least & ((1 << 64) - 1)))) + + +def encoded(tag): + output = io.BytesIO() + nbtlib.File(copy.deepcopy(tag)).write(output) + return output.getvalue() + + +def disk_state(source): + # No serializer snapshot, stopped-event report, or target runtime is read here. + import native12111_actors as driver + definition = native.read_json(source / "actor-fixture.json") + world = source / WORLD + level = read_nbt(world / "level.dat") + if type(level["Data"]["DataVersion"]) is not nbtlib.Int or int(level["Data"]["DataVersion"]) != VERSION: + raise AssertionError("Only an actual stopped native1.21.1 source world is supported") + player_relative = Path("playerdata") / (definition["playerUuid"] + ".dat") + player = read_nbt(world / player_relative) + if (type(player["DataVersion"]) is not nbtlib.Int or int(player["DataVersion"]) != VERSION + or identity(player["UUID"]) != definition["playerUuid"]): + raise AssertionError("Wrong source player identity or native version") + registry = actors.enchantments(source) + player_state = driver.player_semantic(plain(player.unpack()), actors.PLAYER_KEYS, registry) + result = dict(file=player_state, adapter=copy.deepcopy(player_state), entities={}) + if "Player" in level["Data"]: + host = level["Data"]["Player"] + if identity(host["UUID"]) != definition["playerUuid"]: + raise AssertionError("Embedded source host is not the actor witness player") + result["embedded"] = driver.player_semantic(plain(host.unpack()), actors.PLAYER_KEYS, registry) + assets = {"raw/level.dat": (world / "level.dat").read_bytes(), "raw/player.dat": (world / player_relative).read_bytes(), + "raw/actor-fixture.json": (source / "actor-fixture.json").read_bytes()} + metadata, seen = {}, set() + directory = world / "entities" + for region in sorted(directory.iterdir()): + if not region.is_file(): + raise AssertionError("Unexpected directory in source entity storage") + match = storage.REGION_NAME.fullmatch(region.name) + if match is None: + raise AssertionError("Unreviewed source entity storage file: " + region.name) + raw = region.read_bytes() + assets["raw/entities/" + region.name] = raw + for coordinates, payload in storage._payloads(raw, int(match[1]), int(match[2])): + for root in storage._entity_chunk(payload, coordinates, VERSION): + if storage._chunk(root) != coordinates: + raise AssertionError("Source entity position does not match its physical region slot") + for identifier, entity, parent in storage._tree(root): + if identifier in seen: + raise AssertionError("Duplicate source entity UUID in stopped regions") + seen.add(identifier) + data = entity.get("ForgeData", {}) + if "AdvWorksActor" not in data: + continue + witness = str(data["AdvWorksActor"]) + if (type(data["AdvWorksActor"]) is not nbtlib.String or witness not in WITNESSES + or witness in result["entities"] or str(entity["id"]) != WITNESSES[witness]): + raise AssertionError("Missing, duplicate or unexpected source actor witness/type") + if parent is not None or "Passengers" in entity: + raise AssertionError("This bounded stopped actor reference does not model passenger fixtures") + if not definition["minX"] <= float(entity["Pos"][0]) <= definition["maxX"]: + raise AssertionError("Source witness outside the actor fixture bounds") + result["entities"][witness] = driver.historical_semantic(plain(entity.unpack()), actors.ENTITY_KEYS, registry) + asset = "raw/witnesses/" + witness + ".nbt" + assets[asset] = encoded(entity) + metadata[witness] = dict(uuid=identifier, region=region.name, chunk=list(coordinates), rawNbtAsset=asset) + if set(result["entities"]) != set(WITNESSES): + raise AssertionError("Stopped source does not contain the exact five actor witnesses") + return driver.source_components(result, VERSION), metadata, assets + + +def seal(source, fixture_receipt, fixture_sha, output): + source = Path(source).resolve(strict=True) + output = Path(output).resolve() + native.validate_hash(fixture_receipt, fixture_sha) + approved = native.read_json(fixture_receipt) + before = fingerprint(source) + if Path(approved["source"]).resolve() != source or approved["sourceFingerprint"] != before: + raise AssertionError("Stopped source differs from its approved fixture receipt") + require_disjoint(output, source) + require_disjoint(output, fixture_receipt) + output.mkdir(parents=True, exist_ok=False) + state, metadata, assets = disk_state(source) + for name, data in assets.items(): + path = output / name + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("xb") as stream: + stream.write(data) + if fingerprint(source) != before: + raise AssertionError("Source changed while sealing its stopped reference") + document = dict(format=1, scope=SCOPE, source=str(source), sourceDataVersion=VERSION, + sourceFingerprint=before, fixtureReceiptSha256=fixture_sha.upper(), sourceUnchanged=True, + referenceBuilderSha256=digest(Path(__file__)), callerMustStopSourceServer=True, + targetObservationsUsed=False, projection="Unchanged actor oracle keys plus exact complete stack/component payloads", + expected=state, actorBindings=metadata, assets={name: digest(output / name) for name in assets}) + write_json(output / "reference.json", document) + return output / "reference.json" + + +def validate(path, checksum, source, fixture_sha): + path, source = Path(path).resolve(strict=True), Path(source).resolve(strict=True) + native.validate_hash(path, checksum) + document = native.read_json(path) + if (document.get("format") != 1 or document.get("scope") != SCOPE or document.get("source") != str(source) + or document.get("sourceDataVersion") != VERSION or document.get("sourceUnchanged") is not True + or document.get("targetObservationsUsed") is not False or document.get("fixtureReceiptSha256") != fixture_sha.upper() + or document.get("referenceBuilderSha256") != digest(Path(__file__)) + or document.get("sourceFingerprint") != fingerprint(source)): + raise AssertionError("Unbound or changed stopped source reference") + expected, bindings, assets = disk_state(source) + if document["expected"] != expected or document["actorBindings"] != bindings or set(document["assets"]) != set(assets): + raise AssertionError("Stopped golden does not derive exactly from source disk") + expected_files = {"reference.json", *assets} + actual_files = {item.relative_to(path.parent).as_posix() for item in path.parent.rglob("*") if item.is_file()} + if actual_files != expected_files: + raise AssertionError("Unexpected/missing reference asset (target evidence is forbidden)") + for name, raw in assets.items(): + artifact = path.parent / name + native.validate_hash(artifact, document["assets"][name]) + if artifact.read_bytes() != raw: + raise AssertionError("Stopped raw reference asset differs from source bytes") + return copy.deepcopy(expected) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + for name in ("source", "fixture-receipt", "output"): + parser.add_argument("--" + name, type=lambda value: Path(value).resolve(), required=True) + parser.add_argument("--fixture-receipt-sha256", required=True) + args = parser.parse_args() + reference = seal(args.source, args.fixture_receipt, args.fixture_receipt_sha256, args.output) + print(reference) + print(digest(reference)) diff --git a/test-rig/migration/native12111_upgrade.py b/test-rig/migration/native12111_upgrade.py new file mode 100644 index 00000000..b8dea4df --- /dev/null +++ b/test-rig/migration/native12111_upgrade.py @@ -0,0 +1,372 @@ +"""Strict shared family upgrade using sealed historical or modern source receipts.""" + +import argparse +import copy +import gzip +import io +from pathlib import Path +import re +import shutil +import time +import traceback +import zlib + +import nbtlib + +from matrix import WORLD, digest, differences, fingerprint, parse_nbt, plain, write_json +from native117 import native_normalized, output_path, require_disjoint +from native117_readiness import await_ready +from native117_format import decode_palette +from native117_legacy_world import turbine +from native12111 import (EXPLORATORY, FORGE, LAUNCHER, MINECRAFT, Server, java21, read_family_target, read_json, stage, + validate_hash, verify_runtime) +from transformer_order import read_region + + +DATA_VERSION = 4671 +COMPONENT_SOURCE_VERSIONS = {"1.20.1": 3465, "1.20.6": 3839, "1.21.1": 3955} +JOBS = (("sourceChest", "destination", "dirt", "dirt", 8), + ("steamCrusher", "steamCrusher", "stone", "cobblestone", 1), + ("electricFurnace", "electricFurnace", "iron_ore", "iron_ingot", 1)) + + +def components(value): + """Translate only the 1.20.1 item representation, never a 1.21.11 observation.""" + if isinstance(value, list): + return [components(item) for item in value] + if not isinstance(value, dict): + return value + if "Count" not in value or "id" not in value: + return {key: components(item) for key, item in value.items()} + stack = {key: components(item) for key, item in value.items() if key not in ("Count", "tag")} + stack["count"] = value["Count"] + old = dict(value.get("tag", {})) + patch = {} + if "Damage" in old: + patch["minecraft:damage"] = old.pop("Damage") + for before, after in (("Enchantments", "minecraft:enchantments"), + ("StoredEnchantments", "minecraft:stored_enchantments")): + if before in old: + entries = old.pop(before) + levels = {row["id"]: row["lvl"] for row in entries} + if len(levels) != len(entries): + raise AssertionError("Duplicate enchantment witness") + patch[after] = levels + if old: + patch["minecraft:custom_data"] = components(old) + if patch: + stack["components"] = patch + return stack + + +def expected_components(value, *, source_version): + """Adapt a sealed source expectation, never native observed data.""" + if not isinstance(source_version, str) or source_version not in COMPONENT_SOURCE_VERSIONS: + raise AssertionError(f"Unsupported source component version: {source_version!r}") + if source_version == "1.20.1": + return components(value) + identifier = re.compile(r"[a-z0-9_.-]+:[a-z0-9/._-]+") + + def walk(node): + if isinstance(node, list): + return [walk(item) for item in node] + if not isinstance(node, dict): + return copy.deepcopy(node) + if "id" not in node or not any(key in node for key in ("count", "Count")): + return {key: walk(item) for key, item in node.items()} + if "Count" in node or type(node.get("count")) is not int or node["count"] < 1: + raise AssertionError("Modern source stack requires native integer count") + if not isinstance(node["id"], str) or not identifier.fullmatch(node["id"]): + raise AssertionError("Modern source stack requires an exact item identifier") + stack = copy.deepcopy(node) + patch = stack.get("components", {}) + if not isinstance(patch, dict): + raise AssertionError("Modern source components must be a map") + # Unknown stack/component payloads are opaque evidence, not conversion targets. + for name in ("minecraft:enchantments", "minecraft:stored_enchantments"): + if name not in patch: + continue + wrapper = patch[name] + if not isinstance(wrapper, dict) or set(wrapper) != {"levels"}: + raise AssertionError(f"Unexpected source enchantment wrapper: {name}: {wrapper!r}") + levels = wrapper["levels"] + if not isinstance(levels, dict) or any( + not isinstance(key, str) or not identifier.fullmatch(key) + or type(level) is not int or not 1 <= level <= 255 for key, level in levels.items()): + raise AssertionError(f"Invalid source enchantment levels: {name}: {levels!r}") + patch[name] = levels + return stack + + return walk(value) + + +def comparison(before, after, *, modern, source_version="1.20.1"): + def index(rows): + result = {(row["name"], row["x"], row["y"], row["z"]): row for row in rows} + if len(rows) != len(result): + raise AssertionError("Duplicate machine witness") + return result + + first, second = index(before), index(after) + if first.keys() != second.keys(): + raise AssertionError("Machine witness coordinates or names changed") + findings = [] + for identity, left in first.items(): + right = second[identity] + key = str(identity) + for field in ("actualName", "properties"): + if left[field] != right[field]: + findings.append(dict(path=f"{key}.{field}", expected=left[field], actual=right[field])) + if bool(left.get("nbtBase64")) != bool(right.get("nbtBase64")): + findings.append(dict(path=f"{key}.tile", expected=bool(left.get("nbtBase64")), + actual=bool(right.get("nbtBase64")))) + elif left.get("nbtBase64"): + expected = native_normalized(plain(parse_nbt(left))) + if modern: + expected = expected_components(expected, source_version=source_version) + # Native BlockEntity.saveWithFullMetadata always writes its component map, + # including an empty map. Do not discard unexpected observed components. + expected.setdefault("components", {}) + actual = native_normalized(plain(parse_nbt(right))) + findings.extend(differences(expected, actual, key + ".nbt")) + for row in differences(actual, expected, key + ".nbt"): + if row["actual"] == "": + findings.append(dict(path=row["path"], expected="", actual=row["expected"])) + return dict(status="FAIL" if findings else "PASS", witnesses=len(first), differences=findings) + + +def capture(runtime, label, rows): + world = runtime / WORLD + output = runtime / "native12111-save" / label + output.mkdir(parents=True) + shutil.copy2(world / "level.dat", output / "level.dat") + level = nbtlib.load(output / "level.dat")["Data"] + if int(level["DataVersion"]) != DATA_VERSION: + raise AssertionError("Saved level did not identify Minecraft 1.21.11") + regions, chunks = {}, {} + for row in rows: + x, y, z = pos = tuple(row[axis] for axis in ("x", "y", "z")) + cx, cz = key = x // 16, z // 16 + if key not in chunks: + rx, rz = region = cx // 32, cz // 32 + if region not in regions: + _, regions[region] = read_region(world / "region" / f"r.{rx}.{rz}.mca") + payload = regions[region][(cx & 31) + (cz & 31) * 32] + (output / f"{cx}_{cz}.chunk").write_bytes(payload) + if len(payload) < 6 or int.from_bytes(payload[:4], "big") != len(payload) - 4: + raise ValueError("Malformed chunk length") + decoders = {1: gzip.decompress, 2: zlib.decompress, 3: lambda data: data} + chunk = plain(nbtlib.File.parse(io.BytesIO(decoders[payload[4]](payload[5:]))).unpack()) + if chunk["DataVersion"] != DATA_VERSION or "Level" in chunk or (chunk["xPos"], chunk["zPos"]) != key: + raise AssertionError("Saved chunk differs from its native 1.21.11 region slot") + sections, tiles = {}, {} + for section in chunk["sections"]: + section_y = section["Y"] + if section_y in sections or any(k in section for k in ("Blocks", "Palette", "BlockStates")): + raise AssertionError("Duplicate section or old palette") + states = section.get("block_states") + sections[section_y] = decode_palette(states["palette"], states.get("data", [])) if states else None + for tile in chunk.get("block_entities", ()): + location = tile["x"], tile["y"], tile["z"] + if location in tiles: + raise AssertionError("Duplicate saved tile") + tile.pop("keepPacked", None) + tiles[location] = tile + chunks[key] = sections, tiles + sections, tiles = chunks[key] + palette = sections.get(y // 16) + block = palette[((y & 15) << 8) | ((z & 15) << 4) | (x & 15)] if palette else {"Name": "minecraft:air"} + if (block["Name"], block.get("Properties", {})) != (row["actualName"], row["properties"]): + raise AssertionError(f"Saved block differs from live state at {pos}: {block}") + expected = plain(parse_nbt(row)) if row.get("nbtBase64") else None + if tiles.get(pos) != expected: + raise AssertionError(f"Saved block entity differs from live state at {pos}: {tiles.get(pos)} != {expected}") + result = dict(status="PASS", witnesses=len(rows), nativeDataVersion=DATA_VERSION, + worldGenSettings=plain(level["WorldGenSettings"].unpack()), + files={path.name: digest(path) for path in output.iterdir()}) + write_json(output / "receipt.json", result) + return result + + +def copy_source(source, runtime): + protected = fingerprint(source / WORLD) + shutil.copytree(source / WORLD, runtime / WORLD) + shutil.copytree(source / "config", runtime / "config", dirs_exist_ok=True) + if protected != fingerprint(runtime / WORLD): + raise AssertionError("Staged world is not an exact copy") + + +def with_server(runtime, java, label, action): + server = Server(runtime, java, LAUNCHER, label) + try: + action(server) + server.snapshot(label) + server.send("save-all flush") + server.wait("Saved the game", 30) + except BaseException as failure: + try: + server.close() + except BaseException as shutdown: + failure.add_note(f"Shutdown/log audit also failed: {shutdown!r}") + raise + else: + server.close() + return read_json(runtime / f"migration-{label}.json") + + +def count(rows, name, item): + tile = next(row for row in rows if row["name"] == name) + return sum(int(stack["count"]) for stack in parse_nbt(tile).get("Items", ()) if stack["id"] == "minecraft:" + item) + + +def load_operating_chunks(server, runtime, positions): + chunks = sorted({(x >> 4, z >> 4) for x, _, z in positions.values()}) + if not chunks: + raise AssertionError("An operating migration needs explicit machine coordinates") + for cx, cz in chunks: + server.send(f"forceload add {cx << 4} {cz << 4}") + return [await_ready(server, runtime, (cx, cz, cx, cz), f"upgrade-operation-{index}", minecraft=MINECRAFT) + for index, (cx, cz) in enumerate(chunks)] + + +def operate(runtime, frozen, target, java, port, positions, before): + expected = stage(runtime, *target, port) + copy_source(frozen, runtime) + shutil.copy2(frozen / "migration-fixture.json", runtime / "migration-fixture.json") + (runtime / "migration-operate.allow").touch() + + readiness = [] + + def start(server): + readiness.extend(load_operating_chunks(server, runtime, positions)) + for source, _, item, _, amount in JOBS: + x, y, z = positions[source] + server.send(f"item replace block {x} {y} {z} container.0 with minecraft:{item} {amount}") + server.wait("Replaced a slot", 30) + time.sleep(30) + + try: + rows = with_server(runtime, java, "operation", start) + finally: + verify_runtime(runtime, target[1], expected) + results = {"readiness": readiness} + for source, sink, item, product, amount in JOBS: + old, new, remaining = count(before, sink, product), count(rows, sink, product), count(rows, source, item) + if new != old + amount or remaining: + raise AssertionError(f"{sink}: expected {old + amount} {product} and no input, got {new}/{remaining}") + results[sink] = dict(before=old, after=new, item="minecraft:" + product) + return results + + +def run(args): + source = args.source.resolve(strict=True) + reference = args.legacy_reference or source + prior_path = reference / ("native1201-legacy-world-results.json" if args.legacy_reference + else getattr(args, "source_receipt_name", "native1201-modern-upgrade-results.json")) + validate_hash(prior_path, args.receipt_sha256) + prior = read_json(prior_path) + restart = prior["phases"]["restarted"] + # Historical 1.20.1 receipts predate the minecraft field; require their sealed data-version proof. + source_version = prior.get("minecraft", "1.20.1" if args.legacy_reference else None) + source_data_version = restart.get("serialized", {}).get("nativeDataVersion") + if (not isinstance(source_version, str) or source_version not in COMPONENT_SOURCE_VERSIONS + or type(source_data_version) is not int + or source_data_version != COMPONENT_SOURCE_VERSIONS[source_version]): + raise AssertionError(f"Unproven source component version: {source_version!r}/{source_data_version!r}") + if prior["status"] != "PASS" or not prior["sourceUnchanged"] or restart["status"] != "PASS": + raise AssertionError("Missing passing source and restart receipts") + old_snapshot = reference / "migration-restarted.json" + validate_hash(old_snapshot, restart["snapshotSha256"]) + old_rows = read_json(old_snapshot) + target = read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + java, java_version = java21(args.java) + runtime = output_path(args.output) + for protected in (source, reference, args.seed, args.profile, args.inventory, java, *(mod[1] for mod in target[2].values())): + require_disjoint(runtime, protected) + before, seed_before = fingerprint(source), fingerprint(args.seed) + if args.legacy_reference: + def portable(hashes): + return {key.replace("\\", "/"): sha.upper() for key, sha in hashes.items()} + if source != Path(prior["source"]).resolve() or portable(before) != portable(prior["sourceFingerprint"]): + raise AssertionError("Historical source changed since the independent 1.20.1 run") + expected = stage(runtime, *target, args.port) + result = dict(status="RUNNING", qualificationStatus=EXPLORATORY, minecraft=MINECRAFT, forge=FORGE, + source=str(source), reference=str(reference), sourceReceiptSha256=digest(prior_path), profileSha256=digest(args.profile), + inventorySha256=digest(args.inventory), javaSha256=digest(java), javaVersion=java_version, + jars=expected, phases={}) + route = "historical" if args.legacy_reference else "modern" + receipt = runtime / f"native12111-{route}-upgrade-results.json" + write_json(receipt, result) + try: + copy_source(source, runtime) + fixture = read_json(reference / "migration-fixture.json") + write_json(runtime / "migration-fixture.json", fixture) + positions = {row["name"]: [row["x"], row["y"], row["z"]] for row in fixture} + saved = [] + for label in ("converted", "restarted"): + snapshot = runtime / f"migration-{label}.json" + try: + rows = with_server(runtime, java, label, lambda server: None) + except Exception as failure: + if snapshot.is_file(): + try: + phase = comparison(old_rows, read_json(snapshot), modern=True, source_version=source_version) + phase.update(snapshotSha256=digest(snapshot), evidenceStatus="OBSERVATION_ONLY", + reason="Server or log validation failed") + result["phases"][label] = phase + except Exception as diagnostic: + failure.add_note(f"Snapshot comparison also failed: {diagnostic!r}") + raise + phase = comparison(old_rows, rows, modern=True, source_version=source_version) + phase["snapshotSha256"] = digest(snapshot) + result["phases"][label] = phase + write_json(receipt, result) + if phase["status"] != "PASS": + raise AssertionError(f"Strict 1.20.1 witness mismatch in {label}: {phase['differences'][:5]}") + phase["serialized"] = capture(runtime, label, rows) + phase["turbine"] = turbine(runtime / WORLD, positions["hydroGenerator"], data_version=DATA_VERSION) + if phase["turbine"] != restart["turbine"]: + raise AssertionError("Turbine UUID, parent or position changed") + saved.append((rows, phase["serialized"]["worldGenSettings"])) + if saved[0][1] != saved[1][1] or comparison(saved[0][0], saved[1][0], modern=False)["status"] != "PASS": + raise AssertionError("Native machine state or worldgen settings changed during restart") + result["phases"]["operation"] = operate(runtime / "operation", runtime, target, java, args.port, + positions, saved[1][0]) + result["status"] = "PASS" + except BaseException as error: + result.update(status="FAIL", failure=repr(error), traceback=traceback.format_exc()) + raise + finally: + try: + verify_runtime(runtime, target[1], expected) + result["sourceUnchanged"] = fingerprint(source) == before + result["seedUnchanged"] = fingerprint(args.seed) == seed_before + if not result["sourceUnchanged"] or not result["seedUnchanged"]: + raise AssertionError("Protected source or seed changed") + except Exception as audit: + result.update(status="FAIL", auditError=repr(audit)) + finally: + write_json(receipt, result) + return int(result["status"] != "PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for key in ("source", "profile", "seed", "inventory", "java", "output"): + parser.add_argument("--" + key, type=lambda value: Path(value).resolve(), required=True) + parser.add_argument("--legacy-reference", type=lambda value: Path(value).resolve(strict=True), + help="Passing independent 1.20.1 reference for the exact historical source directory") + parser.add_argument("--source-receipt-name", choices=("native1201-modern-upgrade-results.json", + "native1206-modern-upgrade-results.json", "native1211-modern-upgrade-results.json"), + default="native1201-modern-upgrade-results.json") + for key in ("receipt", "inventory"): + parser.add_argument("--" + key + "-sha256", required=True) + parser.add_argument("--port", type=int, required=True) + args = parser.parse_args() + if not 1024 <= args.port <= 65535: + parser.error("A dedicated unprivileged localhost port is required") + return run(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native12111_worldgen.py b/test-rig/migration/native12111_worldgen.py new file mode 100644 index 00000000..c9cde753 --- /dev/null +++ b/test-rig/migration/native12111_worldgen.py @@ -0,0 +1,355 @@ +"""Sample native normal-world deposits and provider/import policy across a full restart. + +Requires a frozen family profile; never changes OreSpawn sources or protected inputs. +""" + +import argparse +import copy +from pathlib import Path +import re +import shutil +import traceback + +import nbtlib + +from matrix import digest, fingerprint, write_json +from native117 import output_path, require_disjoint +import native12111 as native + + +CAMPAIGNS = ("advantage", "legacy-import", "mineralogy") +WORLD = "AdvantageWorksWorldgen" +BLOCKS = {"basepower:crude_oil", "baseelectric:li_ore", "baseelectric:sulfur_ore", + "mineralogy:crude_oil", "mineralogy:sulfur_ore"} +OILS = {"basepower:crude_oil", "mineralogy:crude_oil"} +MINERALOGY = dict(version="6.1.4.121111", sourceRef="d27b800100213c8166a24a7196adf22d05a99bab", + sha256="2ADE7BEE0ACD5B1463752B3D14A01E27D3FFA5448E9407BA8F41DFAFBCA0D954") + + +def require(condition, message): + if not condition: + raise AssertionError(message) + + +class WorldgenServer(native.Server): + @staticmethod + def unexpected_line(line): + return native.Server.unexpected_line(line) or any(marker in line for marker in ( + "Worldgen sample failed", "Advantage Works command failed")) + + +def campaign_spec(name): + if name not in CAMPAIGNS: + raise ValueError("Unknown worldgen campaign: " + name) + template = native.RIG / "profiles/native1201" / f"worldgen-{name}.json" + original = native.read_json(template) + require(original["name"] == "worldgen-" + name and original["levelType"] == "minecraft:normal" + and original["buildWorks"] is False, "Expected the existing normal-world scenario") + keys = ("name", "levelSeed", "worldgenSampleRadius", "firstProfileAssertions", "restartProfileAssertions", + "profileMutations", "firstFileAssertions", "restartFileAssertions", "worldgenExpectations", + "stableWorldgenCountExclusions") + spec = {key: copy.deepcopy(original.get(key, [])) for key in keys} + spec["requiredMods"] = {row["id"] for row in original["mods"]} + spec["template"] = template + spec["configFixtures"] = [] + for row in original.get("configFixtures", []): + destination = Path(row["destination"]) + if destination.name == "poweradvantage.json": + source = native.RIG.parent / "src/test/resources/orespawn-legacy/poweradvantage.json" + elif destination.name == "electricadvantage.json": + source = native.RIG.parent.parent / "ElectricAdvantage/ElectricAdvantage/src/test/resources/orespawn-legacy/electricadvantage.json" + else: + raise ValueError("Unreviewed legacy worldgen fixture: " + str(destination)) + spec["configFixtures"].append((source.resolve(strict=True), destination)) + return spec + + +def select_mods(mods, spec): + missing = spec["requiredMods"] - mods.keys() + if missing: + raise ValueError("Frozen profile lacks worldgen inputs: " + ", ".join(sorted(missing))) + # Base Metals is part of the frozen family, but does not own sulfur/lithium fallbacks. + selected = spec["requiredMods"] | ({"basemetals"} & mods.keys()) + return {key: value for key, value in mods.items() if key in selected} + + +def mineralogy_input(mods, path): + if path is None: + require("mineralogy" in mods, "Mineralogy campaign needs the separately pinned public Mineralogy jar") + value = mods["mineralogy"] + require(value[0] == MINERALOGY["version"] and value[2].upper() == MINERALOGY["sha256"], + "Mineralogy profile does not pin the approved public artifact") + else: + value = (MINERALOGY["version"], path.resolve(strict=True), MINERALOGY["sha256"]) + native.validate_hash(value[1], MINERALOGY["sha256"]) + native.jar_identity(value[1], "mineralogy", MINERALOGY["version"]) + return {**mods, "mineralogy": value} + + +def freeze_mineralogy_profile(runtime, profile, profile_sha256, mods): + frozen = runtime / "frozen-inputs" / mods["mineralogy"][1].name + frozen.parent.mkdir() + shutil.copy2(mods["mineralogy"][1], frozen) + native.validate_hash(frozen, MINERALOGY["sha256"]) + derived = native.read_json(profile) + derived["mods"] = [row for row in derived["mods"] if row["id"] != "mineralogy"] + derived["mods"].append(dict(id="mineralogy", version=MINERALOGY["version"], file=frozen.name, + path=str(frozen), sha256=MINERALOGY["sha256"])) + derived["sourceProfileSha256"] = profile_sha256.upper() + derived["mineralogySourceRef"] = MINERALOGY["sourceRef"] + derived_path = runtime / "frozen-mineralogy-profile.json" + write_json(derived_path, derived) + return dict(path=str(derived_path), sha256=digest(derived_path), + jar=dict(path=str(frozen), **MINERALOGY)) + + +def configure_normal_world(runtime, spec, port): + path = runtime / "server.properties" + overrides = {"level-name": WORLD, "level-type": "minecraft:normal", "generator-settings": "", + "level-seed": spec["levelSeed"], "generate-structures": "true", "server-port": str(port), + "pause-when-empty-seconds": "-1"} + require(all(isinstance(value, str) and "\n" not in value and "\r" not in value + for value in overrides.values()), "Unsafe normal-world property") + lines = [line for line in path.read_text(encoding="utf-8").splitlines() + if line.partition("=")[0] not in overrides] + path.write_text("\n".join(lines + [f"{key}={value}" for key, value in overrides.items()]) + "\n", encoding="utf-8") + + +def validate_profile(document, assertions): + require(document.get("schema_version") == 7, "Expected the pinned native OreSpawn schema 7 profile") + rows = [] + missing = object() + for assertion in assertions: + section = document.get(assertion["section"], {}) + require(isinstance(section, dict), "Profile section is not an object") + value = section.get(assertion["entry"], missing) + if assertion.get("field"): + value = value.get(assertion["field"], missing) if isinstance(value, dict) else missing + present = value is not missing + require(present == assertion["present"], "Wrong profile presence: " + assertion["id"]) + if present: + for field, kind in (("expectedBoolean", bool), ("expectedString", str), ("expectedNumber", (int, float))): + if field in assertion: + require(isinstance(value, kind) and not (field == "expectedNumber" and isinstance(value, bool)) + and value == assertion[field], "Wrong profile value: " + assertion["id"]) + rows.append(dict(id=assertion["id"], actual=value if present else None, status="PASS")) + return rows + + +def validate_initial_hierarchy(document, mods, legacy=False): + if legacy: + return + ores, deposits = document["ores"], document["fluid_deposits"] + require(deposits["basepower:fluid_deposit/desert_crude_oil"]["source_provider"] == "basepower", + "Desert oil lost its Power provider identity") + mineralogy, minerals = "mineralogy" in mods, "baseminerals" in mods + for material, enabled in (("sulfur", not mineralogy and not minerals), ("lithium", not minerals)): + rule = ores["baseelectric:ore/" + material] + require(rule["source_provider"] == "baseelectric" and rule["enabled"] is enabled, + "Wrong fresh-world sulfur/lithium provider hierarchy: " + material) + if mineralogy: + require(deposits["mineralogy:fluid_deposit/crude_oil"]["source_provider"] == "mineralogy", + "Ocean oil lost its Mineralogy provider identity") + + +def mutated_profile(document, mutations): + result = copy.deepcopy(document) + for mutation in mutations: + entry = result[mutation["section"]][mutation["entry"]] + require(isinstance(entry, dict) and mutation["field"] in entry, "Mutation must target an existing profile field") + values = [mutation[key] for key in ("booleanValue", "stringValue", "numberValue") if key in mutation] + require(len(values) == 1, "Profile mutation must declare exactly one value") + entry[mutation["field"]] = values[0] + return result + + +def validate_sample(document, spec, mods): + require((document.get("minecraft"), document.get("forge"), document.get("station"), document.get("kind")) + == (native.MINECRAFT, native.FORGE, "worldgen", "sample"), "Missing native completed worldgen receipt") + require(all(document.get("mods", {}).get(key) == value[0] for key, value in mods.items()), + "Sampler mod versions differ from the frozen inputs") + radius = spec["worldgenSampleRadius"] + require(document.get("radius") == radius, "Wrong worldgen sampling radius") + scanned = document.get("chunksScanned") + require(type(scanned) is int and 0 < scanned <= 3 * (2 * radius + 1) ** 2, "Invalid sampled chunk count") + centers = document.get("centers", {}) + for biome in (("DESERT", "OCEAN") if "mineralogy" in mods else ("DESERT",)): + require(isinstance(centers.get(biome), str) and re.fullmatch(r"-?\d+,-?\d+", centers[biome]), + "Required native biome was not found: " + biome) + for field, keys in (("counts", BLOCKS), ("biomeViolations", OILS), ("biomeBoundaryBlocks", OILS)): + values = document.get(field) + require(isinstance(values, dict) and set(values) == keys + and all(type(value) is int and value >= 0 for value in values.values()), + "Incomplete or invalid worldgen measurements: " + field) + expectations = spec["worldgenExpectations"] + for key, minimum in expectations["minimumCounts"].items(): + require(document["counts"][key] >= minimum, "Missing actual generated deposit: " + key) + for key, maximum in expectations["maximumCounts"].items(): + require(document["counts"][key] <= maximum, "Unexpected provider output: " + key) + if expectations["zeroBiomeViolations"]: + require(not any(document["biomeViolations"].values()), "Oil generated outside its allowed native biome") + loading = document.get("chunkLoading", {}) + require(loading.get("protocol") == "native_FULL_off_thread_future_poll_v1" + and loading.get("maximumInFlight") == 4 and type(loading.get("peakInFlight")) is int + and 0 < loading["peakInFlight"] <= 4 and loading.get("activeTickets") == 0 + and type(loading.get("requests")) is int and loading["requests"] >= scanned * 2 + and loading.get("releasedTickets") == loading["requests"] + and loading.get("dispatcherClosed") is True and loading.get("executorShutdown") is True, + "Native FULL chunk sampling did not release all bounded tickets and its dispatcher") + + +def validate_restart(first, restart, spec): + require(first["centers"] == restart["centers"] and first["chunksScanned"] == restart["chunksScanned"], + "Worldgen sampling coverage moved across restart") + exclusions = set(spec["stableWorldgenCountExclusions"]) + require(exclusions <= {"basepower:crude_oil"} and + (not exclusions or spec["name"] == "worldgen-legacy-import"), "Unreviewed restart count exclusion") + for field in ("counts", "biomeViolations"): + require({key: value for key, value in first[field].items() if key not in exclusions} + == {key: value for key, value in restart[field].items() if key not in exclusions}, + "Generated deposit measurements changed across full restart: " + field) + + +def normal_world(data): + require(data.get("DataVersion") == 4671 and data.get("LevelName") == WORLD, + "Expected a stopped native 1.21.11 world") + settings = data["WorldGenSettings"] + generator = settings["dimensions"]["minecraft:overworld"]["generator"] + require(generator.get("type") == "minecraft:noise" and generator.get("settings") == "minecraft:overworld", + "Worldgen must exercise actual normal terrain, not the flat machine fixture") + require(type(settings.get("seed")) is int, "Missing saved native seed") + return copy.deepcopy(settings) + + +def check_files(runtime, assertions): + for row in assertions: + path = (runtime / row["path"]).resolve() + require(path.is_relative_to(runtime.resolve()), "File assertion escaped the disposable runtime") + require(path.is_file(), "Missing import witness: " + row["id"]) + if row.get("contains") is not None: + require(path.read_text(encoding="utf-8").count(row["contains"]) >= row["minimumOccurrences"], + "Incomplete import witness: " + row["id"]) + + +def run(args): + if not 1024 <= args.port <= 65535 or not 300 <= args.sample_timeout <= 3600: + raise ValueError("Use an unprivileged port and a bounded 300..3600-second sample deadline") + native.validate_hash(args.profile, args.profile_sha256) + target = native.read_family_target(args.profile, args.seed, args.inventory, args.inventory_sha256) + java, version = native.java21(args.java) + spec = campaign_spec(args.campaign) + candidates = mineralogy_input(target[2], args.mineralogy) if args.campaign == "mineralogy" else target[2] + mods = select_mods(candidates, spec) + runtime = output_path(args.output) + protected_paths = [args.profile, args.inventory, java, spec["template"], Path(__file__).resolve(), + *(value[1] for value in candidates.values()), *(source for source, _ in spec["configFixtures"])] + for path in [args.seed, *protected_paths]: + require_disjoint(runtime, path) + hashes = {str(path): digest(path) for path in protected_paths} + if args.preflight_only: + return dict(status="READY_NOT_RUN", campaign=args.campaign, minecraft=native.MINECRAFT, + forge=native.FORGE, inputs=hashes, selectedMods=sorted(mods)) + seed_before = fingerprint(args.seed) + expected = {value[1].name: value[2].upper() for value in mods.values()} + report = dict(status="RUNNING", qualificationStatus=native.EXPLORATORY, campaign=args.campaign, + minecraft=native.MINECRAFT, forge=native.FORGE, javaVersion=version, + inputs=hashes, jars=expected, phases={}, sampleTimeoutSeconds=args.sample_timeout) + receipt = runtime / "native12111-worldgen-results.json" + server, failed = None, False + try: + native.stage(runtime, target[0], target[1], mods, args.port) + shutil.copy2(Path(__file__), runtime / "adapter-source.py") + if args.campaign == "mineralogy": + report["mineralogyProfile"] = freeze_mineralogy_profile(runtime, args.profile, args.profile_sha256, mods) + hashes[report["mineralogyProfile"]["jar"]["path"]] = MINERALOGY["sha256"] + configure_normal_world(runtime, spec, args.port) + for source, destination in spec["configFixtures"]: + path = runtime / destination + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, path) + write_json(receipt, report) + first, world_settings, pids = None, None, [] + profile_file = runtime / WORLD / "serverconfig/orespawn-worldgen.json" + for phase in ("initial", "restart"): + print(f"{args.campaign} {phase}: starting pinned normal-world server", flush=True) + native.verify_runtime(runtime, target[1], expected) + server = WorldgenServer(runtime, java, native.LAUNCHER, "worldgen-" + phase) + pids.append(server.process.pid) + require(len(set(pids)) == len(pids), "Restart must be a new server process") + server.send("gamerule doMobSpawning false") + server.send("advworks sample-worldgen " + str(spec["worldgenSampleRadius"])) + server.wait("Worldgen sample wrote ", args.sample_timeout) + sample_file = runtime / "advantage-works-results/worldgen-sample.json" + sample = native.read_json(sample_file) + shutil.copy2(sample_file, runtime / f"worldgen-{phase}.json") + validate_sample(sample, spec, mods) + print(f"{args.campaign} {phase}: sampled {sample['chunksScanned']} chunks; {sample['counts']}", flush=True) + server.send("save-all flush") + server.wait("Saved the game", 30) + server.close() + server = None + level = runtime / WORLD / "level.dat" + shutil.copy2(level, runtime / f"level-{phase}.dat") + settings = normal_world(nbtlib.load(level)["Data"].unpack()) + profile = native.read_json(profile_file) + write_json(runtime / f"orespawn-profile-{phase}.json", profile) + prefix = "first" if phase == "initial" else "restart" + assertions = validate_profile(profile, spec[prefix + "ProfileAssertions"]) + check_files(runtime, spec[prefix + "FileAssertions"]) + for source, destination in spec["configFixtures"]: + require(digest(runtime / destination) == hashes[str(source)], "Legacy import rewrote its original source") + if first is None: + validate_initial_hierarchy(profile, mods, legacy=args.campaign == "legacy-import") + first, world_settings = sample, settings + if spec["profileMutations"]: + write_json(profile_file, mutated_profile(profile, spec["profileMutations"])) + else: + validate_restart(first, sample, spec) + require(settings == world_settings, "Saved normal-world settings changed across restart") + report["phases"][phase] = dict(status="PASS", pid=pids[-1], profileAssertions=assertions, + sampleSha256=digest(runtime / f"worldgen-{phase}.json"), + stoppedLevelSha256=digest(runtime / f"level-{phase}.dat")) + write_json(receipt, report) + report["status"] = "PASS" + except BaseException as error: + failed = True + report.update(status="FAIL", failure=repr(error), traceback=traceback.format_exc()) + raise + finally: + try: + if server is not None: + server.close() + if runtime.exists(): + native.verify_runtime(runtime, target[1], expected) + report["seedUnchanged"] = fingerprint(args.seed) == seed_before + require(report["seedUnchanged"], "Protected Forge seed changed") + require(all(digest(Path(path)) == sha for path, sha in hashes.items()), "Frozen source input changed") + except BaseException as error: + report.update(status="FAIL", custodyError=repr(error)) + if not failed: + raise + finally: + if runtime.exists(): + write_json(receipt, report) + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("profile", "seed", "inventory", "java", "output"): + parser.add_argument("--" + name, required=True, type=lambda value: Path(value).resolve()) + parser.add_argument("--profile-sha256", required=True) + parser.add_argument("--inventory-sha256", required=True) + parser.add_argument("--port", required=True, type=int) + parser.add_argument("--campaign", required=True, choices=CAMPAIGNS) + parser.add_argument("--sample-timeout", type=int, default=1800) + parser.add_argument("--preflight-only", action="store_true") + parser.add_argument("--mineralogy", type=lambda value: Path(value).resolve(), + help="Approved public Mineralogy jar; copied and pinned separately in campaign evidence") + result = run(parser.parse_args()) + print(result["status"], result["campaign"], flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/test_audit_native12111_endurance.py b/test-rig/migration/test_audit_native12111_endurance.py new file mode 100644 index 00000000..1a0a8f16 --- /dev/null +++ b/test-rig/migration/test_audit_native12111_endurance.py @@ -0,0 +1,34 @@ +import json +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +from audit_native12111_endurance import Forge61 + + +class NativeEnduranceReplayTest(unittest.TestCase): + def test_replay_requires_a_matching_positive_semantic_checkpoint(self): + documents = {station: {"station": station} for station in + ("S-04", "E-03", "W-04", "W-03", "E-02")} + definitions = {"W-03": {"id": "W-03"}} + measured = {"status": "PASS", "measurements": {"oil": 1000}} + with TemporaryDirectory() as directory: + runtime = Path(directory) + checkpoint = runtime / "semantic-batch-cycle-001.json" + checkpoint.write_text(json.dumps(measured), encoding="utf-8") + with patch("audit_native12111_endurance.measure_batch", return_value=measured) as measure: + Forge61.verify_batch(runtime, "cycle-001", documents, definitions) + measure.assert_called_once_with({key: value for key, value in documents.items() + if key != "E-02"}, definitions) + + checkpoint.write_text(json.dumps({"status": "PASS", "measurements": {"oil": 999}}), + encoding="utf-8") + with self.assertRaisesRegex(AssertionError, "replay disagrees"): + Forge61.verify_batch(runtime, "cycle-001", documents, definitions) + + failed = {"status": "FAIL", "failures": ["missing oil"]} + checkpoint.write_text(json.dumps(failed), encoding="utf-8") + measure.return_value = failed + with self.assertRaisesRegex(AssertionError, "replay disagrees"): + Forge61.verify_batch(runtime, "cycle-001", documents, definitions) diff --git a/test-rig/migration/test_native12111.py b/test-rig/migration/test_native12111.py new file mode 100644 index 00000000..0e8108ee --- /dev/null +++ b/test-rig/migration/test_native12111.py @@ -0,0 +1,179 @@ +import copy +import json +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch + +import nbtlib + +import endurance +import native12111 as native +import semantic_machine_audit +import test_semantic_machine_audit +from native117_identity import target_id +from native12111_upgrade import comparison, components +from native12111_broad import Adapter +from native12111_endurance import EnduranceHold, Rig, check_hold, measure_batch + + +class Native1211Test(unittest.TestCase): + def test_priming_is_atomic_and_always_releases_the_native_tick_gate(self): + rig = Rig("fresh", Path("runtime"), Path("java"), native.LAUNCHER, {}, {}, {}) + for method in ("start", "seed"): + calls = [] + rig.command = lambda command, response=None: calls.append(command) + with patch("native12111_endurance.NativeEnduranceRig." + method, + side_effect=RuntimeError("deliberate priming failure")): + with self.assertRaisesRegex(RuntimeError, "priming failure"): + getattr(rig, method)() + self.assertEqual(["tick freeze", "tick unfreeze"], calls) + + def test_endurance_decoding_is_local_and_preserves_raw_checkpoint(self): + row = {"pos": [1, 2, 3], "nbt": '{Items:[{Slot:0b,id:"baseelectric:lithium_battery",count:1,' + 'components:{"minecraft:custom_data":{energy:5.5f},"minecraft:damage":2}}]}'} + document = {"snapshot": [row]} + before = copy.deepcopy(document) + native_stack = native.parse_endurance_snapshot(document)[(1, 2, 3)]["data"]["Items"][0] + self.assertEqual(1, native_stack["Count"]) + self.assertEqual({"energy": 5.5}, native_stack["tag"]) + self.assertEqual(2, native_stack["components"]["minecraft:damage"]) + self.assertEqual(before, document) + self.assertNotIn("Count", endurance.parse_snapshot(document)[(1, 2, 3)]["data"]["Items"][0]) + legacy = {"snapshot": [{"pos": [1, 2, 3], "nbt": '{Items:[{Slot:0b,id:"minecraft:dirt",Count:2b}]}'}]} + self.assertEqual(2, endurance.parse_snapshot(legacy)[(1, 2, 3)]["data"]["Items"][0]["Count"]) + with self.assertRaisesRegex(AssertionError, "native 1.21.11 stack"): + native.parse_endurance_snapshot(legacy) + + def test_native_semantic_view_keeps_historical_positive_and_negative_oracles(self): + fixture = test_semantic_machine_audit.IndependentEnduranceTest() + fixture.setUp() + legacy = fixture.documents() + definitions = fixture.rig.definitions + self.assertEqual("PASS", semantic_machine_audit.endurance_batch(legacy, definitions)["status"]) + modern = copy.deepcopy(legacy) + for document in modern.values(): + for row in document["snapshot"]: + row["block"] = target_id(row["block"]) + data = nbtlib.parse_nbt(row["nbt"]) + for stack in data.get("Items", []): + stack["count"] = nbtlib.Int(stack.pop("Count")) + for name in ("Tank", "InputTank"): + if "FluidName" in data.get(name, {}): + data[name]["FluidName"] = nbtlib.String(target_id(str(data[name]["FluidName"]))) + row["nbt"] = data.snbt() + before = copy.deepcopy(modern) + self.assertEqual("PASS", measure_batch(modern, definitions)["status"]) + self.assertEqual(before, modern) + row = modern["W-03"]["snapshot"][0] + data = nbtlib.parse_nbt(row["nbt"]) + self.assertIn("Tank", data) + del data["Tank"] + row["nbt"] = data.snbt() + result = measure_batch(modern, definitions) + self.assertEqual("FAIL", result["status"]) + self.assertIn("Missing oil tank evidence", result["failures"]) + + def test_endurance_custody_does_not_shadow_native_inventory_commands(self): + rig = Rig("fresh", Path("runtime"), Path("java"), native.LAUNCHER, {}, {"files": {}}, {}) + self.assertTrue(callable(rig.inventory)) + self.assertEqual({"files": {}}, rig.code_inventory) + + def test_endurance_hold_requires_an_explicit_request_file(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + check_hold(root) + (root / "hold-endurance.request").touch() + with self.assertRaises(EnduranceHold): + check_hold(root) + + def test_broad_witness_requires_native_tile_and_item_components(self): + marker = '"S-05:down-facing"' + row = {"nbt": Adapter.drill_tag(marker)} + decoded = Adapter.drill_data(row) + self.assertEqual("S-05:down-facing", decoded["ForgeData"]["AdvBayWitness"]) + self.assertEqual("S-05:down-facing", decoded["Items"][0]["tag"]["AdvBayStock"]) + self.assertEqual(7, decoded["Items"][0]["Count"]) + self.assertIn("components", decoded["Items"][0]) + with self.assertRaisesRegex(AssertionError, "native tile component"): + Adapter.drill_data({"nbt": '{ForgeData:{AdvBayWitness:"legacy"}}'}) + with self.assertRaisesRegex(AssertionError, "native 1.21.11 stack"): + native.compat_tile_data({"nbt": '{Items:[{id:"minecraft:diamond",Count:7b}]}'}) + + def test_java_version_gate_rejects_non_21_runtime(self): + with tempfile.TemporaryDirectory() as directory: + java = Path(directory) / "java" + java.touch() + result = SimpleNamespace(stdout="", stderr='openjdk version "17.0.1"') + with patch.object(native.subprocess, "run", return_value=result): + with self.assertRaisesRegex(ValueError, "requires Java 21"): + native.java21(java) + result.stderr = 'openjdk version "21.0.12.1"' + self.assertEqual((java, result.stderr), native.java21(java)) + + def test_modern_components_preserve_exact_enchantment_wear_and_custom_payload(self): + before = {"id": "basesteam:musket", "Count": 1, "Slot": 3, "tag": { + "Damage": 5, "Enchantments": [{"id": "basesteam:powderless", "lvl": 1}], + "Witness": "legacy"}} + after = {"id": "basesteam:musket", "count": 1, "Slot": 3, "components": { + "minecraft:damage": 5, "minecraft:enchantments": {"basesteam:powderless": 1}, + "minecraft:custom_data": {"Witness": "legacy"}}} + self.assertEqual(after, components(before)) + witness = {"name": "chest", "x": 1, "y": 5, "z": 1, "actualName": "minecraft:chest", + "properties": {}, "nbtBase64": "fixture"} + with patch("native12111_upgrade.parse_nbt", side_effect=[{"Items": [before]}, {"Items": [after], "components": {}}]): + self.assertEqual("PASS", comparison([witness], [witness], modern=True)["status"]) + extra = dict(after, components={**after["components"], "minecraft:repair_cost": 10}) + with patch("native12111_upgrade.parse_nbt", side_effect=[{"Items": [before]}, {"Items": [extra], "components": {}}]): + self.assertEqual("FAIL", comparison([witness], [witness], modern=True)["status"]) + with patch("native12111_upgrade.parse_nbt", side_effect=[{"Items": [before]}, {"Items": [dict(after, count=2)], "components": {}}]): + self.assertEqual("FAIL", comparison([witness], [witness], modern=True)["status"]) + for observed in ({"Items": [after]}, {"Items": [after], "components": {"other:unexpected": 1}}): + with patch("native12111_upgrade.parse_nbt", side_effect=[{"Items": [before]}, observed]): + self.assertEqual("FAIL", comparison([witness], [witness], modern=True)["status"]) + + def test_requires_one_successful_shared_crushing_witness(self): + row = {"id": "basemetals/native-power-crushing", "status": "PASS"} + native.require_shared_crushing({"sharedChecks": [row]}) + for rows in ([], [row, row], [dict(row, status="FAIL")], [dict(row, id="other")]): + with self.subTest(rows=rows), self.assertRaises(AssertionError): + native.require_shared_crushing({"sharedChecks": rows}) + + def test_inventory_rejects_modified_launcher_even_with_the_same_shim(self): + with tempfile.TemporaryDirectory() as directory: + seed = Path(directory) + launcher = seed / native.LAUNCHER + launcher.parent.mkdir(parents=True) + launcher.write_text("-Djava.net.preferIPv6Addresses=system -jar " + native.SHIM) + with patch.object(native, "validate_hash"): + self.assertIn(native.LAUNCHER, native.inspect_seed(seed)["files"]) + launcher.write_text(launcher.read_text() + " --unexpected") + with self.assertRaisesRegex(AssertionError, "official shim"): + native.inspect_seed(seed) + + def test_profile_does_not_carry_historical_artifact_pins_or_drop_assertions(self): + sha = "A" * 64 + versions = {"basepower": "2.4.15.121111", "advantageworkstest": "1.1.5.121111", "orespawn": "4.1.0.121111"} + mods = {key: (version, native.RIG / f"{key}-{version}.jar", sha) for key, version in versions.items()} + fixture = native.read_json(native.RIG / "profiles/native1201/worldgen-legacy-import.json") + output = native.RIG / "build/test-generated-profile.json" + args = SimpleNamespace(name="worldgen-legacy-import", output=output, profile=native.BASE_PROFILE, + steam=Path("baseSteam-2.2.16.121111.jar"), steam_sha256=sha, + electric=Path("baseElectric-2.2.16.121111.jar"), electric_sha256=sha, + mineralogy=None, mineralogy_sha256=None) + with (patch.object(native, "read_mods", return_value=mods), patch.object(native, "validate_hash"), + patch.object(native, "jar_identity"), patch.object(native, "write_json") as write): + native.materialize_profile(args) + _, document = write.call_args.args + for key in ("firstProfileAssertions", "restartProfileAssertions", "configFixtures", "worldgenExpectations"): + self.assertEqual(fixture.get(key), document.get(key), key) + self.assertNotIn("powerDeobfSha256", document) + self.assertNotIn("sourceCommit", document) + self.assertEqual("1.21.11", document["minecraftVersion"]) + self.assertEqual(native.EXPLORATORY, document["qualificationStatus"]) + self.assertTrue(all(entry["version"].endswith(".121111") for entry in document["mods"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_actor_comparison.py b/test-rig/migration/test_native12111_actor_comparison.py new file mode 100644 index 00000000..12fe7f2a --- /dev/null +++ b/test-rig/migration/test_native12111_actor_comparison.py @@ -0,0 +1,99 @@ +import copy +import unittest + +import nbtlib + +import native12111_actor_comparison as diagnosis + + +class NativeActorComparisonTest(unittest.TestCase): + def fixture(self, motion=90.5, timestamp=101): + tag = nbtlib.Compound({"id": nbtlib.String("minecraft:cow"), + "UUID": nbtlib.IntArray([0, 0, 0, 1]), + "NoAI": nbtlib.Byte(1), "NoGravity": nbtlib.Byte(1), + "Health": nbtlib.Float(995.2774), + "Motion": nbtlib.List[nbtlib.Double]([motion, 0.383453342391226, 0]), + "HurtByTimestamp": nbtlib.Int(timestamp)}) + after = copy.deepcopy(tag) + after["Motion"][0] = nbtlib.Double(0) + after["HurtByTimestamp"] = nbtlib.Int(0) + return tag, after + + def compare(self, before, after, case_id="steam/extra-explosive-1"): + def case(tag): + return {"id": case_id, "positions": [], "entities": [ + {"uuid": "00000000-0000-0000-0000-000000000001", "nbt": tag.snbt()}]} + fixture = {"cases": [case(before)]} + inspected = {"cases": [case(after)], "held": True, "constructionCallsThisProcess": 0} + original = copy.deepcopy((fixture, inspected)) + result = diagnosis.diagnose(fixture, inspected) + self.assertEqual(original, (fixture, inspected)) + return result + + def test_exact_known_deltas_do_not_upgrade_the_frozen_failure(self): + result = self.compare(*self.fixture()) + self.assertTrue(result["diagnosticOnly"]) + self.assertEqual("FAIL", result["frozenRestartResult"]["status"]) + self.assertEqual({"$.Motion[0]", "$.HurtByTimestamp"}, {r["path"] for r in result["actorDifferences"]}) + self.assertEqual(0, result["unexplainedActorDifferences"]) + + def test_velocity_boundary_and_in_range_damage_are_not_canonicalized(self): + for velocity in (10.0, -10.0, 0.5): + with self.subTest(velocity=velocity): + result = self.compare(*self.fixture(velocity, 0)) + self.assertEqual(1, result["unexplainedActorDifferences"]) + + def test_exact_negative_outlier_is_explained(self): + self.assertEqual(0, self.compare(*self.fixture(-90.6844709742601, 0))["unexplainedActorDifferences"]) + + def test_reversing_the_native_load_transition_is_not_explained(self): + before, after = self.fixture() + self.assertEqual(2, self.compare(after, before)["unexplainedActorDifferences"]) + + def test_health_damage_and_unknown_keys_remain_failures(self): + for change in ("health", "add", "remove"): + before, after = self.fixture() + if change == "health": + after["Health"] = nbtlib.Float(1) + elif change == "add": + after["unknown"] = nbtlib.Int(7) + else: + del after["NoGravity"] + result = self.compare(before, after) + self.assertGreater(result["unexplainedActorDifferences"], 0) + self.assertEqual("FAIL", result["frozenRestartResult"]["status"]) + + def test_known_shape_in_another_case_is_not_explained(self): + result = self.compare(*self.fixture(), case_id="steam/another-case") + self.assertEqual(2, result["unexplainedActorDifferences"]) + + def test_relative_timestamp_or_attacker_reference_is_never_ignored(self): + for name in ("last_hurt_by_mob", "ticks_since_last_hurt_by_mob"): + before, after = self.fixture() + before[name] = nbtlib.Int(7) + after[name] = nbtlib.Int(7) + self.assertEqual(1, self.compare(before, after)["unexplainedActorDifferences"]) + + def test_motion_list_type_change_and_nonzero_clamped_result_fail(self): + before, after = self.fixture(timestamp=0) + after["Motion"][0] = nbtlib.Double(1) + self.assertEqual(1, self.compare(before, after)["unexplainedActorDifferences"]) + before, after = self.fixture(timestamp=0) + after["Motion"] = nbtlib.List[nbtlib.Float]([0, 0, 0]) + self.assertEqual(1, self.compare(before, after)["unexplainedActorDifferences"]) + + def test_saved_uuid_cannot_disagree_with_actor_metadata(self): + before, after = self.fixture() + after["UUID"] = nbtlib.IntArray([0, 0, 0, 2]) + with self.assertRaisesRegex(AssertionError, "metadata and saved UUID"): + self.compare(before, after) + + def test_duplicate_actor_uuid_is_rejected(self): + before, _ = self.fixture() + actor = {"uuid": "00000000-0000-0000-0000-000000000001", "nbt": before.snbt()} + with self.assertRaisesRegex(AssertionError, "Duplicate"): + diagnosis.actors({"entities": [actor, copy.deepcopy(actor)]}) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_actor_fixtures.py b/test-rig/migration/test_native12111_actor_fixtures.py new file mode 100644 index 00000000..e2f4f34d --- /dev/null +++ b/test-rig/migration/test_native12111_actor_fixtures.py @@ -0,0 +1,61 @@ +import copy +import unittest + +import nbtlib +from native12111_actor_fixtures import BATTERIES, legal_ender_batteries +from native12111_actors import source_components + + +class NativeActorFixtureControls(unittest.TestCase): + def item(self, identity="electricadvantage:lithium_battery", count=3, slot=1): + return nbtlib.Compound(id=nbtlib.String(identity), Count=nbtlib.Byte(count), Slot=nbtlib.Byte(slot), + tag=nbtlib.Compound(energy=nbtlib.Double(123), Nested=nbtlib.Compound( + OwnerNote=nbtlib.String("keep-this-verbatim"), Items=nbtlib.List[nbtlib.Int]([7, 5])))) + + def test_known_split_keeps_totals_metadata_types_and_unique_slots(self): + original = nbtlib.Compound(EnderItems=nbtlib.List[nbtlib.Compound]([ + self.item(identity=identity, slot=index) for index, identity in enumerate(sorted(BATTERIES)[:4])])) + before = copy.deepcopy(original) + edits = legal_ender_batteries(original) + self.assertEqual(4, len(edits)) + self.assertEqual(12, len(original["EnderItems"])) + self.assertEqual(12, len(set(int(row["Slot"]) for row in original["EnderItems"]))) + for first in before["EnderItems"]: + after = [row for row in original["EnderItems"] if row["id"] == first["id"]] + self.assertEqual(3, sum(int(row["Count"]) for row in after)) + self.assertTrue(all(type(row["Count"]) is nbtlib.Byte and row["tag"] == first["tag"] for row in after)) + self.assertEqual(12, sum(int(row["Count"]) for row in before["EnderItems"])) + + def test_unknown_items_and_other_containers_are_not_normalized(self): + player = nbtlib.Compound(EnderItems=nbtlib.List[nbtlib.Compound]([self.item("other:unknown", 7)]), + Inventory=nbtlib.List[nbtlib.Compound]([self.item()])) + before = copy.deepcopy(player) + self.assertEqual([], legal_ender_batteries(player)) + self.assertEqual(before, player) + + def test_unreviewed_count_duplicate_or_full_slots_fail_without_changes(self): + variants = [nbtlib.Compound(EnderItems=nbtlib.List[nbtlib.Compound]([self.item(count=2)])), + nbtlib.Compound(EnderItems=nbtlib.List[nbtlib.Compound]([self.item(), self.item()])), + nbtlib.Compound(EnderItems=nbtlib.List[nbtlib.Compound]([self.item(slot=0), + *(self.item("other:item", 1, slot) for slot in range(1, 27))]))] + for player in variants: + before = copy.deepcopy(player) + with self.subTest(player=player), self.assertRaises(AssertionError): + legal_ender_batteries(player) + self.assertEqual(before, player) + + def test_native1211_expectation_flattens_only_enchantment_wrapper(self): + stack = {"id": "basesteam:musket", "count": 1, "Slot": 0, "components": { + "minecraft:enchantments": {"levels": {"basesteam:recoil": 3}}, + "minecraft:damage": 5, "minecraft:custom_data": {"Nested": {"id": "opaque", "Count": 19}}}} + before = copy.deepcopy(stack) + result = source_components(stack, 3955) + self.assertEqual({"basesteam:recoil": 3}, result["components"]["minecraft:enchantments"]) + self.assertEqual(stack["components"]["minecraft:custom_data"], result["components"]["minecraft:custom_data"]) + self.assertEqual(before, stack) + with self.assertRaises(ValueError): + source_components(stack, 3956) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_actor_helper_contract.py b/test-rig/migration/test_native12111_actor_helper_contract.py new file mode 100644 index 00000000..c839c043 --- /dev/null +++ b/test-rig/migration/test_native12111_actor_helper_contract.py @@ -0,0 +1,39 @@ +import copy +import hashlib +from pathlib import Path +import unittest + +import native12111_actors as native + + +class NativeActorHelperContractTest(unittest.TestCase): + def setUp(self): + self.contract = (Path(__file__).resolve().parents[1] / "src/main/resources/assets/advantageworkstest" + / "native12111-helper-contract.json").read_bytes() + self.document = dict(helperPolicy="native12111-leash-knot-nosave-v1", + helperContractSha256=native.HELPER_CONTRACT_SHA, + dimensions={}, entityCount=0, nonpersistentHelperCount=0, observedEntityCount=0) + + def test_driver_uses_the_exact_current_native_helper_evidence(self): + self.assertEqual(native.HELPER_CONTRACT_SHA, hashlib.sha256(self.contract).hexdigest().upper()) + result = native.validate_helpers(self.document, self.contract) + self.assertEqual("NOT_REQUIRED", result["status"]) + + def test_historical_or_modified_contract_bytes_cannot_qualify(self): + historical = (Path(__file__).resolve().parents[1] / "src/main/resources/assets/advantageworkstest" + / "native1211-helper-contract.json").read_bytes() + for payload in (historical, self.contract + b" "): + with self.assertRaisesRegex(AssertionError, "evidence pin differs"): + native.validate_helpers(self.document, payload) + + def test_stopped_observation_cannot_claim_another_policy_or_hash(self): + for field, value in (("helperPolicy", "native1211-leash-knot-nosave-v1"), + ("helperContractSha256", "0" * 64)): + document = copy.deepcopy(self.document) + document[field] = value + with self.assertRaisesRegex(AssertionError, "policy differs"): + native.validate_helpers(document, self.contract) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_actor_network_adapters.py b/test-rig/migration/test_native12111_actor_network_adapters.py new file mode 100644 index 00000000..9b29b142 --- /dev/null +++ b/test-rig/migration/test_native12111_actor_network_adapters.py @@ -0,0 +1,168 @@ +"""Fail-closed controls for the Forge 61 lifecycle format adapters.""" + +import copy +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import nbtlib + +import actors +import dimensions +import native12111_actors as actor_driver +import native12111_networks as network_driver +from matrix import WORLD + + +class ActorAdapters(unittest.TestCase): + def test_native_attribute_view_keeps_values_and_opaque_modifiers(self): + original = {"attributes": [{"id": "forge:swim_speed", "base": 2.5, "modifiers": [ + {"id": "other:current", "amount": 0.125, "operation": "add_value"}]}]} + before = copy.deepcopy(original) + actual = actor_driver.native_attribute_view(original) + self.assertEqual({"Attributes": [{"Name": "forge:swim_speed", "Base": 2.5, "Modifiers": [ + {"id": "other:current", "amount": 0.125, "operation": "add_value"}]}]}, actual) + self.assertEqual(before, original) + for malformed in ({"attributes": [{"id": "x:y"}, {"id": "x:y"}]}, + {"attributes": [], "Attributes": []}): + with self.assertRaises(ValueError): + actor_driver.native_attribute_view(malformed) + + def test_preserves_enchantments_damage_slot_count_and_opaque_custom_data(self): + item = {"id": "basesteam:musket", "Count": 2, "Slot": -106, "tag": { + "Damage": 5, "Enchantments": [{"id": "basesteam:recoil", "lvl": 3}], + "StoredEnchantments": [{"id": "basesteam:powderless", "lvl": 1}], + "Nested": {"id": "minecraft:dirt", "Count": 17}, + }} + original = copy.deepcopy(item) + self.assertEqual({ + "id": "basesteam:musket", "count": 2, "Slot": -106, "components": { + "minecraft:damage": 5, + "minecraft:enchantments": {"basesteam:recoil": 3}, + "minecraft:stored_enchantments": {"basesteam:powderless": 1}, + "minecraft:custom_data": {"Nested": {"id": "minecraft:dirt", "Count": 17}}, + }, + }, actor_driver.expected_components(item)) + self.assertEqual(original, item) + + def test_rejects_missing_or_added_native_stack_components(self): + expected = {"file": {"Inventory": [{"id": "minecraft:dirt", "Slot": 3, "count": 1, + "components": {"minecraft:custom_data": {"Note": "original"}}}]}, + "entities": {}} + self.assertEqual("PASS", actor_driver.verdict(actors.compare, expected, copy.deepcopy(expected))["status"]) + for components in ({}, {"minecraft:custom_data": {"Note": "original"}, "minecraft:damage": 7}): + actual = copy.deepcopy(expected) + actual["file"]["Inventory"][0]["components"] = components + result = actor_driver.verdict(actors.compare, expected, actual) + self.assertEqual("FAIL", result["status"]) + self.assertEqual("FAIL", result["player_status"]) + self.assertTrue(any(row["path"].endswith(".exact-item") for row in result["differences"])) + + def test_rejects_ambiguous_native_and_historical_stack(self): + with self.assertRaises(AssertionError): + actor_driver.expected_components({"id": "minecraft:dirt", "Count": 2, "count": 2}) + + def test_dimension_projection_preserves_actual_leash_armor_and_jump_strength(self): + armor = {"id": "minecraft:iron_horse_armor", "count": 1, + "components": {"minecraft:custom_data": {"Note": "witness"}}} + horse = {"id": "minecraft:horse", "leash": [168, 100, 8], "body_armor_item": armor, + "Attributes": [{"Name": "minecraft:generic.jump_strength", "Base": 0.7}]} + result = actor_driver.dimension_entity_view(horse) + self.assertEqual({"X": 168, "Y": 100, "Z": 8}, result["Leash"]) + self.assertEqual(armor, result["ArmorItem"]) + self.assertEqual([{"Name": "horse.jumpStrength", "Base": 0.7}], result["Attributes"]) + + def test_ambiguous_or_malformed_native_leash_is_not_normalized(self): + for tag in ({"leash": [168, 100, True]}, {"leash": [168, 100, 8], "Leash": {"X": 5}}): + with self.subTest(tag=tag), self.assertRaises(ValueError): + actor_driver.dimension_entity_view({"id": "minecraft:horse", **tag}) + + def test_fixture_copy_does_not_replace_native_forge_config(self): + with tempfile.TemporaryDirectory() as folder: + source, runtime = Path(folder) / "source", Path(folder) / "runtime" + (source / WORLD).mkdir(parents=True) + (source / "config").mkdir() + (source / "migration-fixture.json").write_text("{}", encoding="utf-8") + (source / "config/poweradvantage.cfg").write_text("historical", encoding="utf-8") + (runtime / "config").mkdir(parents=True) + (runtime / "config/fml.toml").write_text("native", encoding="utf-8") + actor_driver.copy_fixture(source, runtime) + self.assertEqual("native", (runtime / "config/fml.toml").read_text(encoding="utf-8")) + self.assertEqual("historical", (runtime / "config/poweradvantage.cfg").read_text(encoding="utf-8")) + + +class NativeDifferenceAssessment(unittest.TestCase): + def setUp(self): + self.before = {"entities": {"passenger-wolf": { + "id": "minecraft:wolf", "Health": 8, + "Attributes": {"generic.maxHealth": {"Base": 20}}, + "UUIDMost": 5, "UUIDLeast": 7, "OwnerUUID": "owner"}}, + "player": {"Inventory": [{"id": "minecraft:dirt", "Count": 17}]}} + self.after = copy.deepcopy(self.before) + self.after["entities"]["passenger-wolf"]["Health"] = 16 + self.after["entities"]["passenger-wolf"]["Attributes"]["generic.maxHealth"]["Base"] = 40 + + def assess(self, actual, proof=Path("controls")): + strict = actor_driver.verdict(dimensions.verdict, self.before, actual) + with patch.object(actor_driver.native, "validate_hash") as hashes, \ + patch.object(actor_driver.native, "read_json", return_value={"noPower": True}): + result = actor_driver.assess_native(self.before, actual, strict, proof, "scope", None) + return result, hashes + + def test_only_native_wolf_change_is_classified_and_raw_failure_is_kept(self): + result, hashes = self.assess(self.after) + self.assertEqual("PASS_WITH_NATIVE_DIFFERENCE", result["status"]) + self.assertEqual("FAIL", result["rawStrictResult"]["status"]) + self.assertEqual(2, hashes.call_count) + self.assertEqual(8, self.before["entities"]["passenger-wolf"]["Health"]) + + def test_unrelated_inventory_loss_owner_change_or_wrong_health_stays_failed(self): + variants = [] + for key, value in (("OwnerUUID", "another"), ("Health", 15), ("UUIDLeast", 9)): + changed = copy.deepcopy(self.after) + changed["entities"]["passenger-wolf"][key] = value + variants.append(changed) + changed = copy.deepcopy(self.after) + changed["player"]["Inventory"][0]["Count"] = 16 + variants.append(changed) + for actual in variants: + with self.subTest(actual=actual): + self.assertEqual("FAIL", self.assess(actual)[0]["status"]) + self.assertEqual("FAIL", self.assess(self.after, proof=None)[0]["status"]) + + def test_control_hash_failure_is_not_accepted(self): + strict = actor_driver.verdict(dimensions.verdict, self.before, self.after) + with patch.object(actor_driver.native, "validate_hash", side_effect=ValueError("changed control")): + with self.assertRaises(ValueError): + actor_driver.assess_native(self.before, self.after, strict, Path("controls"), "scope", None) + + +class NetworkAdapters(unittest.TestCase): + def test_native_witness_inventory_command(self): + item = 'minecraft:dirt{AdvWorksWitness:"S-EXT-north"}' + self.assertEqual('minecraft:dirt[minecraft:custom_data={AdvWorksWitness:"S-EXT-north"}]', + network_driver.native_inventory_item(item)) + self.assertEqual("minecraft:apple", network_driver.native_inventory_item("minecraft:apple")) + + def test_compatibility_view_retains_native_components_and_does_not_edit_observations(self): + source = {"callbacks": {"drill": {"after": {"nbt": + '{Items:[{id:"minecraft:dirt",Slot:0b,count:7,' + 'components:{"minecraft:custom_data":{AdvWorksWitness:"test"},"other:component":5}}]}'}}}} + original = copy.deepcopy(source) + projected = network_driver.compatibility_view(source) + stack = nbtlib.parse_nbt(projected["callbacks"]["drill"]["after"]["nbt"])["Items"][0] + self.assertEqual(7, stack["count"]) + self.assertEqual(7, stack["Count"]) + self.assertEqual("test", stack["tag"]["AdvWorksWitness"]) + self.assertEqual(5, stack["components"]["other:component"]) + self.assertEqual(original, source) + + def test_legacy_or_badly_typed_stack_is_not_native_evidence(self): + for count in ("Count:7b", "count:7b", "count:0"): + with self.subTest(count=count), self.assertRaises(AssertionError): + network_driver.compatibility_view({"nbt": '{Items:[{id:"minecraft:dirt",' + count + '}]}'}) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_broad_policy.py b/test-rig/migration/test_native12111_broad_policy.py new file mode 100644 index 00000000..dd5d1318 --- /dev/null +++ b/test-rig/migration/test_native12111_broad_policy.py @@ -0,0 +1,167 @@ +"""Native61 broad CLI policy and orchestration, not Minecraft qualification.""" + +from contextlib import redirect_stderr, redirect_stdout +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +import native12111_broad as broad +from test_broad_campaign_status import passed + + +class Native12111BroadPolicyTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="native61-broad-policy-", dir=broad.native.RIG / "build") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name).resolve() + self.output = self.root / "new-output" + self.seed = self.root / "seed" + self.seed.mkdir() + (self.seed / "protected-input").write_bytes(b"seed must not change") + self.profile = self.root / "profile.json" + self.inventory_file = self.root / "inventory.json" + self.profile.write_text("{}", encoding="utf-8") + self.inventory_file.write_text("{}", encoding="utf-8") + self.java = self.root / "java.exe" + self.java.write_bytes(b"unit fixture, never launched") + self.mods = {} + for name in ("basepower", "basesteam", "baseelectric", "basemetals", "orespawn", "advantageworkstest"): + path = self.root / (name + ".jar") + path.write_bytes(name.encode("ascii")) + self.mods[name] = ("unit.121111", path, broad.digest(path)) + self.inventory = {"fixture": "orchestration only"} + + def arguments(self, mode=None): + args = ["--profile", str(self.profile), "--seed", str(self.seed), + "--inventory", str(self.inventory_file), "--inventory-sha256", "A" * 64, + "--java", str(self.java), "--output", str(self.output), "--port", "26460"] + return args if mode is None else args + ["--mode", mode] + + def run_main(self, arguments): + with patch.object(sys, "argv", [str(Path(broad.__file__)), *arguments]), redirect_stdout(io.StringIO()): + return broad.main() + + def assert_unsupported(self, mode): + error = io.StringIO() + with patch.object(broad, "output_path") as output, \ + patch.object(broad.native, "read_family_target") as inputs, \ + patch.object(broad.native, "java21") as java, \ + patch.object(broad, "run_profile") as launch, redirect_stderr(error): + with self.assertRaises(SystemExit) as failure: + self.run_main(self.arguments(mode)) + self.assertEqual(failure.exception.code, 2) + for mock in (output, inputs, java, launch): + mock.assert_not_called() + self.assertIn("BaseMetals", error.getvalue()) + self.assertIn("1.21.11", error.getvalue()) + self.assertIn("packaged-current", error.getvalue()) + return error.getvalue() + + def test_both_and_decoupled_refuse_before_inputs_output_or_launch(self): + for mode in ("both", "packaged-decoupled"): + with self.subTest(mode=mode): + message = self.assert_unsupported(mode) + self.assertIn(mode, message) + self.assertFalse(self.output.exists()) + + def test_refusal_does_not_relabel_or_overwrite_an_old_failure(self): + self.output.mkdir() + receipt = self.output / "native12111-broad-results.json" + old = b'{"status":"FAIL","profiles":{"packaged-decoupled":{"status":"FAIL"}}}\n' + receipt.write_bytes(old) + for mode in ("both", "packaged-decoupled"): + with self.subTest(mode=mode): + self.assert_unsupported(mode) + self.assertEqual(receipt.read_bytes(), old) + self.assertEqual(list(self.output.iterdir()), [receipt]) + + def exercise_supported(self, mode=None, *, current_failure=False, custody_failure=False): + result = (dict(status="FAIL", profile="packaged-current", error="measured station failed") + if current_failure else passed("packaged-current")) + with patch.object(broad.native, "read_family_target", return_value=(self.seed, self.inventory, self.mods)), \ + patch.object(broad.native, "java21", return_value=(self.java, 'java version "21-unit"')), \ + patch.object(broad, "run_profile", return_value=result) as launch, \ + patch.object(broad.native, "verify_runtime", side_effect=( + AssertionError("staged jar changed") if custody_failure else None)) as custody: + exit_code = self.run_main(self.arguments(mode)) + self.assertEqual(launch.call_count, 1) + args, kwargs = launch.call_args + self.assertEqual(args[2:5], (self.output, "packaged-current", 26460)) + self.assertEqual(len(args[5]), 21) + self.assertEqual(set(kwargs["native118_target"][2]), set(self.mods)) + expected = {path.name: checksum for _, path, checksum in self.mods.values()} + custody.assert_called_once_with(self.output / "packaged-current", self.inventory, expected) + document = json.loads((self.output / "native12111-broad-results.json").read_text(encoding="utf-8")) + self.assertEqual(set(document["profiles"]), {"packaged-current"}) + self.assertEqual(set(document["jars"]), set(self.mods)) + self.assertTrue(document["seedUnchanged"]) + return exit_code, document + + def test_default_dispatches_only_supported_current_with_all_six_pins(self): + exit_code, document = self.exercise_supported() + self.assertEqual(exit_code, 0) + self.assertEqual(document["status"], "PASS") + + def test_explicit_current_remains_supported(self): + exit_code, document = self.exercise_supported("packaged-current") + self.assertEqual(exit_code, 0) + self.assertEqual(document["status"], "PASS") + + def test_supported_station_failure_stays_failure(self): + exit_code, document = self.exercise_supported(current_failure=True) + self.assertEqual(exit_code, 1) + self.assertEqual(document["status"], "FAIL") + self.assertIn("measured station failed", document["profiles"]["packaged-current"]["error"]) + + def test_supported_custody_failure_stays_failure(self): + exit_code, document = self.exercise_supported(custody_failure=True) + self.assertEqual(exit_code, 1) + self.assertEqual(document["status"], "FAIL") + self.assertIn("staged jar changed", document["profiles"]["packaged-current"]["custodyError"]) + + def test_missing_basemetals_refuses_before_output_or_java(self): + self.mods.pop("basemetals") + error = io.StringIO() + with patch.object(broad.native, "read_family_target", return_value=(self.seed, self.inventory, self.mods)), \ + patch.object(broad, "output_path") as output, patch.object(broad.native, "java21") as java, \ + patch.object(broad, "run_profile") as launch, redirect_stderr(error): + with self.assertRaises(SystemExit) as failure: + self.run_main(self.arguments()) + self.assertEqual(failure.exception.code, 2) + output.assert_not_called() + java.assert_not_called() + launch.assert_not_called() + self.assertIn("BaseMetals", error.getvalue()) + self.assertFalse(self.output.exists()) + + def test_real_cli_rejects_unsupported_even_without_other_arguments(self): + for mode in ("both", "packaged-decoupled"): + with self.subTest(mode=mode): + process = subprocess.run([sys.executable, "-B", str(Path(broad.__file__).resolve()), "--mode", mode], + capture_output=True, text=True, timeout=20) + self.assertEqual(process.returncode, 2) + self.assertIn("BaseMetals", process.stderr) + self.assertIn("packaged-current", process.stderr) + self.assertNotIn("Traceback", process.stderr) + self.assertEqual(process.stdout, "") + + def test_real_cli_refuses_unsupported_before_reading_missing_profile(self): + self.profile.unlink() + for mode in ("both", "packaged-decoupled"): + with self.subTest(mode=mode): + process = subprocess.run([sys.executable, "-B", str(Path(broad.__file__).resolve()), + *self.arguments(mode)], capture_output=True, text=True, timeout=20) + self.assertEqual(process.returncode, 2) + self.assertIn("BaseMetals", process.stderr) + self.assertNotIn("Traceback", process.stderr) + self.assertEqual(process.stdout, "") + self.assertFalse(self.output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_commands.py b/test-rig/migration/test_native12111_commands.py new file mode 100644 index 00000000..c919f0e1 --- /dev/null +++ b/test-rig/migration/test_native12111_commands.py @@ -0,0 +1,121 @@ +import unittest +import tempfile +from pathlib import Path +from zipfile import ZipFile +from unittest.mock import patch +import native12111 as native +import queue +from types import SimpleNamespace + + +class NativeCommandsTest(unittest.TestCase): + def test_empty_server_pause_is_an_immediate_failed_wait(self): + server = object.__new__(native.Server) + server.lines = queue.Queue() + server.lines.put("[Server thread/INFO]: Server empty for 60 seconds, pausing\n") + server.process = SimpleNamespace(poll=lambda: None) + server.log = SimpleNamespace(name="fixture.log") + with self.assertRaisesRegex(RuntimeError, "pausing"): + server.wait("unreached machine completion", 1) + + def test_pause_is_rejected_even_at_info_level_in_saved_logs(self): + failures, _ = native.log_findings("[11Nov2026 12:00:00] [Server thread/INFO] [minecraft/MinecraftServer/]: Server empty for 60 seconds, pausing") + self.assertEqual(1, len(failures)) + + def test_legacy_rule_is_translated_and_requires_a_native_acknowledgement(self): + server = object.__new__(native.Server) + with patch.object(native.NativeLaunch, "send") as send, patch.object(server, "wait") as wait: + server.send("gamerule doMobSpawning false") + send.assert_called_once_with("gamerule minecraft:spawn_mobs false") + wait.assert_called_once_with("Gamerule spawn_mobs is now set to: false", 15) + + def test_missing_acknowledgement_fails_the_fixture_setup(self): + server = object.__new__(native.Server) + with patch.object(native.NativeLaunch, "send"), patch.object(server, "wait", side_effect=TimeoutError("no ack")): + with self.assertRaisesRegex(TimeoutError, "no ack"): + server.send("gamerule randomTickSpeed 0") + + def test_unknown_old_rule_cannot_silently_leave_the_world_uncontrolled(self): + server = object.__new__(native.Server) + with patch.object(native.NativeLaunch, "send") as send: + with self.assertRaisesRegex(ValueError, "Unknown legacy gamerule"): + server.send("gamerule inventedOldRule false") + send.assert_not_called() + + def test_non_rule_commands_keep_the_original_path(self): + server = object.__new__(native.Server) + with patch.object(native.NativeLaunch, "send") as send: + server.send("save-all flush") + send.assert_called_once_with("save-all flush") + + +class NativeLoggingTest(unittest.TestCase): + def fixture(self, root, count=2): + jar = root / "loader.jar" + xml = '' + '' * count + xml += '' + with ZipFile(jar, "w") as output: + output.writestr("log4j2.xml", xml) + server = object.__new__(native.Server) + server.native_runtime = root + return server, jar + + def test_only_extended_rendering_changes_and_all_log_levels_are_kept(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + server, jar = self.fixture(root) + before = native.digest(jar) + uri = server.logging_configuration(jar) + config = root / "forge61-log4j2-plain-stacktraces.xml" + tree = native.ET.fromstring(config.read_bytes()) + self.assertEqual(config.as_uri(), uri) + self.assertEqual(["%msg%n%ex"] * 2, [node.get("pattern") for node in tree.iter("PatternLayout")]) + self.assertEqual("all", tree.find("./Loggers/Root").get("level")) + self.assertEqual("debug", tree.find("./Loggers/Root/AppenderRef").get("level")) + self.assertEqual(before, native.digest(jar)) + self.assertEqual(uri, server.logging_configuration(jar)) + + def test_a_changed_native_format_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + server, jar = self.fixture(Path(directory), count=1) + with self.assertRaisesRegex(AssertionError, "logging changed"): + server.logging_configuration(jar) + + def test_runtime_logging_tampering_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + server, jar = self.fixture(root) + server.logging_configuration(jar) + config = root / "forge61-log4j2-plain-stacktraces.xml" + config.write_text(config.read_text().replace('level="all"', 'level="off"')) + with self.assertRaisesRegex(AssertionError, "configuration changed"): + server.logging_configuration(jar) + + +class NativeDiagnosticClassificationTest(unittest.TestCase): + def probe(self, level="DEBUG", cause="Only supported on OSX/BSD", extra=""): + return (f"[11Nov2026 12:00:00.000] [Server thread/{level}] [io.netty.channel.kqueue.KQueue/]: KQueue support is not available\n" + "java.lang.ExceptionInInitializerError\n\tat io.netty.channel.kqueue.KQueue.test(KQueue.java:1)\n" + f"Caused by: java.lang.IllegalStateException: {cause}\n" + "\tat io.netty.channel.kqueue.Native.test(Native.java:1)\n\t... 7 more\n" + extra) + + def test_exact_debug_probe_is_retained_as_a_platform_observation(self): + errors, observations = native.log_findings(self.probe()) + self.assertEqual([], errors) + self.assertEqual(1, len(observations)) + self.assertEqual("DEBUG", observations[0]["level"]) + self.assertIn("Caused by:", "\n".join(observations[0]["stack"])) + + def test_error_level_and_unrecognized_causes_are_never_accepted(self): + for text in (self.probe(level="ERROR"), self.probe(cause="Inventory was lost")): + self.assertTrue(native.log_findings(text)[0]) + + def test_a_second_exception_cannot_hide_under_the_debug_probe(self): + self.assertTrue(native.log_findings(self.probe(extra="java.lang.IllegalStateException: lost inventory\n"))[0]) + + def test_a_mod_stack_frame_is_not_a_platform_probe(self): + self.assertTrue(native.log_findings(self.probe(extra="\tat zone.moddev.mc.basepower.Machine.tick(Machine.java:1)\n"))[0]) + + def test_an_unrelated_runtime_error_after_a_probe_still_fails(self): + error = "[11Nov2026 12:00:01.000] [Server thread/ERROR] [basepower/]: Recipe load failed\n" + self.assertTrue(native.log_findings(self.probe(extra=error))[0]) diff --git a/test-rig/migration/test_native12111_dimension_layout.py b/test-rig/migration/test_native12111_dimension_layout.py new file mode 100644 index 00000000..ec72f879 --- /dev/null +++ b/test-rig/migration/test_native12111_dimension_layout.py @@ -0,0 +1,117 @@ +"""Native dimension layouts keep respawn, boat, attribute and horse-gear evidence.""" + +import copy +import hashlib +from pathlib import Path +import unittest +import zipfile + +import dimensions +import native12111_actors as native + + +class NativeDimensionLayoutTest(unittest.TestCase): + def test_respawn_all_fields_migrate_without_editing_source(self): + before = {"SpawnX": 160, "SpawnY": 100, "SpawnZ": 24, "SpawnDimension": -1, + "SpawnAngle": 37.5, "SpawnForced": 1, "Health": 20} + original = copy.deepcopy(before) + expected = {"respawn": {"pos": [160, 100, 24], "dimension": "minecraft:the_nether", + "yaw": 37.5, "pitch": 0.0, "forced": 1}, "Health": 20} + self.assertEqual(expected, native.expected_player_respawn(before)) + self.assertEqual(original, before) + self.assertEqual(expected, native.expected_player_respawn(expected)) + + def test_respawn_omitted_vanilla_defaults_are_explicit_and_false_forced_is_not_written(self): + before = {"SpawnX": 1, "SpawnY": 2, "SpawnZ": 3, "SpawnForced": 0} + self.assertEqual({"respawn": {"pos": [1, 2, 3], "dimension": "minecraft:overworld", + "yaw": 0.0, "pitch": 0.0}}, native.expected_player_respawn(before)) + + def test_partial_malformed_or_ambiguous_respawn_is_rejected(self): + for value in ({"SpawnX": 1}, {"SpawnAngle": 10}, + {"SpawnX": 1, "SpawnY": 2, "SpawnZ": 3, "respawn": {}}, + {"SpawnX": 1, "SpawnY": 2, "SpawnZ": 3, "SpawnAngle": float("nan")}, + {"SpawnX": 1, "SpawnY": 2, "SpawnZ": 3, "SpawnForced": 2}, + {"SpawnX": 1, "SpawnY": 2, "SpawnZ": 3, "SpawnDimension": "other:unknown"}): + with self.subTest(value=value), self.assertRaises(ValueError): + native.expected_player_respawn(value) + + def test_capture_keeps_legacy_angle_and_complete_native_respawn(self): + self.assertEqual({"SpawnAngle": 19.25}, native.player_semantic({"SpawnAngle": 19.25}, dimensions.PLAYER_STATE, {})) + value = {"respawn": {"pos": [1, 2, 3], "dimension": "minecraft:the_end", + "yaw": 41.5, "pitch": -12.0, "forced": 1, "other:opaque": 7}} + actual = native.player_semantic(value, dimensions.PLAYER_STATE, {}) + self.assertEqual(value, actual) + with self.assertRaises(ValueError): + native.player_semantic({**value, "SpawnAngle": 19.25}, dimensions.PLAYER_STATE, {}) + + def test_each_respawn_field_missing_or_changed_or_added_remains_red(self): + before = {"file": {"respawn": {"pos": [1, 2, 3], "dimension": "minecraft:the_end", + "yaw": 37.5, "pitch": 0.0, "forced": 1}}, "entities": {}} + for key, changed in (("pos", [1, 2, 4]), ("dimension", "minecraft:overworld"), + ("yaw", 0.0), ("pitch", 10.0), ("forced", 0), ("unknown", 5)): + for missing in (False, True): + actual = copy.deepcopy(before) + if missing: + actual["file"]["respawn"].pop(key, None) + else: + actual["file"]["respawn"][key] = changed + if missing and key == "unknown": + continue + with self.subTest(key=key, missing=missing): + self.assertEqual("FAIL", native.verdict(dimensions.verdict, before, actual)["status"]) + + def test_all_eighteen_boat_variants_have_exact_species_identity(self): + for chest in (False, True): + for variant in native.BOAT_VARIANTS: + entity = {"id": "minecraft:chest_boat" if chest else "minecraft:boat", "Type": variant, + "UUIDMost": 5, "UUIDLeast": 7, "Passengers": []} + native.expected_dimension_entity(entity) + suffix = ("_chest" if chest else "") + ("_raft" if variant == "bamboo" else "_boat") + self.assertEqual("minecraft:" + variant + suffix, entity["id"]) + self.assertNotIn("Type", entity) + self.assertEqual((5, 7), (entity["UUIDMost"], entity["UUIDLeast"])) + with self.assertRaises(ValueError): + native.expected_dimension_entity({"id": "minecraft:boat", "Type": "unproven"}) + + def test_horse_equipment_is_complete_exact_and_ambiguous_sources_rejected(self): + stack = {"id": "minecraft:saddle", "count": 1, "components": {"minecraft:custom_data": {"Note": "intact"}}} + horse = {"id": "minecraft:horse", "SaddleItem": stack, "ArmorItem": {"id": "minecraft:iron_horse_armor", "count": 1}} + native.expected_dimension_entity(horse) + self.assertEqual({"saddle", "body"}, set(horse["equipment"])) + self.assertEqual(stack, horse["equipment"]["saddle"]) + self.assertEqual(horse, native.dimension_entity_state(horse)) + before = {"entities": {"horse": horse}} + for slot in ("saddle", "body"): + after = copy.deepcopy(before) + del after["entities"]["horse"]["equipment"][slot] + self.assertEqual("FAIL", native.verdict(dimensions.verdict, before, after)["status"]) + for extra in ("saddle", "other"): + with self.assertRaises(ValueError): + native.expected_dimension_entity({"id": "minecraft:horse", "SaddleItem": stack, "equipment": {extra: stack}}) + with self.assertRaises(ValueError): + native.dimension_entity_state({"id": "minecraft:horse", "SaddleItem": stack, "equipment": {"saddle": stack}}) + + def test_attribute_aliases_change_only_known_id_spellings(self): + original = {"attributes": [{"id": key, "base": 3.5} for key in native.NATIVE_ATTRIBUTE_ALIASES]} + original["attributes"].append({"id": "other:max_health", "base": 8.5, "modifiers": [{"unknown": 7}]}) + before = copy.deepcopy(original) + actual = native.native_attribute_view(original)["Attributes"] + self.assertEqual([*native.NATIVE_ATTRIBUTE_ALIASES.values(), "other:max_health"], [a["Name"] for a in actual]) + self.assertEqual(8.5, actual[-1]["Base"]) + self.assertEqual([{"unknown": 7}], actual[-1]["Modifiers"]) + self.assertEqual(before, original) + with self.assertRaises(ValueError): + native.native_attribute_view({"attributes": [{"id": "minecraft:max_health"}, {"id": "minecraft:generic.max_health"}]}) + + def test_primary_equipment_source_is_the_reviewed_native61_archive(self): + path = (Path(__file__).resolve().parents[1] / ".gradle/mavenizer/repo/net/minecraftforge/forge" + / "1.21.11-61.1.0/forge-1.21.11-61.1.0-sources.jar") + self.assertEqual("DFE912C3B1E769B60DA2B4C5EA0B7F9171637B26DA640A9D85DE8CA683B30661", native.digest(path)) + with zipfile.ZipFile(path) as archive: + source = archive.read("net/minecraft/util/datafix/fixes/PlayerEquipmentFix.java") + self.assertEqual("DC9E526B9695B1EF635914AF051B99945CC1399FE5AC813BF172CD7A3E8CF13F", + hashlib.sha256(source).hexdigest().upper()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_endurance_replay.py b/test-rig/migration/test_native12111_endurance_replay.py new file mode 100644 index 00000000..fa62f083 --- /dev/null +++ b/test-rig/migration/test_native12111_endurance_replay.py @@ -0,0 +1,404 @@ +"""Physical native61 region fixtures and fail-closed frozen replay controls.""" + +import base64 +import copy +import gzip +import io +import json +from pathlib import Path +import tempfile +import unittest +import zlib + +import nbtlib as n + +import native12111_endurance_replay as replay +from native12111_endurance import measure_batch +from native117_identity import target_id +from semantic_machine_audit import BATCH_MACHINES + + +def encoded(root): + stream = io.BytesIO() + n.File(root).write(stream) + return stream.getvalue() + + +def stack(): + return n.Compound(Slot=n.Byte(0), id=n.String("baseelectric:lead_acid_battery"), count=n.Int(1), + components=n.Compound({"minecraft:custom_data": n.Compound(energy=n.Float(100))})) + + +def tile(): + return n.Compound(id=n.String("basepower:test"), x=n.Int(1), y=n.Int(1), z=n.Int(1), + components=n.Compound({"minecraft:custom_data": n.Compound(witness=n.Int(7))}), + Items=n.List[n.Compound]([stack()]), Energy=n.IntArray([1065353216])) + + +def chunk(): + saved = tile() + saved["keepPacked"] = n.Byte(0) + return n.Compound(DataVersion=n.Int(4671), xPos=n.Int(0), zPos=n.Int(0), + sections=n.List[n.Compound]([n.Compound(Y=n.Byte(0), block_states=n.Compound( + palette=n.List[n.Compound]([n.Compound(Name=n.String("basepower:test"))])))]), + block_entities=n.List[n.Compound]([saved])) + + +def payload(root, compression=2): + raw = encoded(root) + data = {1: gzip.compress, 2: zlib.compress, 3: lambda v: v}[compression](raw) + return (len(data) + 1).to_bytes(4, "big") + bytes([compression]) + data + + +def region(data): + count = (len(data) + 4095) // 4096 + header = bytearray(8192) + header[:4] = ((2 << 8) | count).to_bytes(4, "big") + return bytes(header) + data + bytes(count * 4096 - len(data)) + + +def document(value=None): + return dict(snapshot=[dict(pos=[1, 1, 1], block="basepower:test", state="Block{basepower:test}", + nbt=(tile() if value is None else value).snbt())]) + + +def put_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + +def stopped_fixture(root): + world = root / replay.WORLD + (world / "region").mkdir(parents=True) + (world / "level.dat").write_bytes(gzip.compress(encoded(n.Compound(Data=n.Compound(DataVersion=n.Int(4671)))))) + (world / "region/r.0.0.mca").write_bytes(region(payload(chunk()))) + (world / "session.lock").write_bytes(b"abc") + (root / "console-endurance-restart.log").write_text("Stopping server\nAll dimensions are saved\n", encoding="utf-8") + for station in replay.endurance.CHECKED: + put_json(root / "advantage-works-results" / f"{station}-checkpoint-restart-resumed.json", document()) + return world + + +def complete_report(): + return dict(status="PASS", minecraft="1.21.11", forge="61.1.0", duration_seconds=1800, + interval_seconds=60, seedUnchanged=True, retained_unchanged=True, + checked_stations=replay.endurance.CHECKED, active_stations=replay.endurance.ACTIVE, + profiles={mode: dict(status="PASS", restart="PASS", resumed_processing="PASS", + endurance_batches=30, verified_checkpoints=33, + game_ticks=36000, elapsed_seconds=1801) for mode in replay.MODES}) + + +def semantic_fixture(): + documents = {name: dict(station=name, snapshot=[]) for name in ("S-04", "E-03", "W-03", "W-04")} + definitions = {name: dict(origin=[0, 0, 0]) for name in documents} + + def put(station, pos, block="minecraft:chest", items=(), **fields): + value = n.Compound(Items=n.List[n.Compound]([ + n.Compound(Slot=n.Byte(slot), id=n.String(item), count=n.Int(count)) for slot, item, count in items + ]), **fields) + documents[station]["snapshot"].append(dict(pos=list(pos), block=target_id(block), + state="Block{" + target_id(block) + "}", nbt=value.snbt())) + + for station, pos, neighbours, block, product, _ in BATCH_MACHINES: + put(station, pos, block, [(0, "minecraft:" + product, 4)]) + for neighbour in neighbours: + put(station, neighbour) + for x in (2, 3, 4, 5): + put("W-04", (x, 1, 6), items=[(0, "minecraft:dirt", 16)] if x == 5 else []) + for z, ghosts in ((9, [(1, "minecraft:dirt", 1), (2, "minecraft:cobblestone", 1), (3, "minecraft:coal", 1)]), + (15, []), (18, [(1, "minecraft:dirt", 1)])): + put("W-04", (2, 1, z)) + put("W-04", (3, 1, z), items=ghosts) + forward = [(0, "minecraft:apple", 8)] + ([(1, "minecraft:dirt", 8)] if z == 15 else []) + put("W-04", (4, 1, z), items=forward) + put("W-04", (3, 0, z), items=[] if z == 15 else [(0, "minecraft:dirt", 8)]) + for x in (4, 6, 8): + tank = n.Compound(FluidName=n.String("basepower:refined_oil"), Amount=n.Int(1000)) if x == 8 else n.Compound(Empty=n.Byte(1)) + put("W-03", (x, 1, 8), Tank=tank, **({"InputTank": n.Compound(Empty=n.Byte(1))} if x == 6 else {})) + return documents, definitions + + +class Native61ReplayTests(unittest.TestCase): + def test_all_33_batch_outputs_recomputed_and_changed_product_fails(self): + documents, definitions = semantic_fixture() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for label in [f"cycle-{i:03d}" for i in range(1, 31)] + ["before-restart", "after-restart", "restart-resumed"]: + measured = measure_batch(documents, definitions) + self.assertEqual(measured["status"], "PASS") + put_json(root / f"semantic-batch-{label}.json", measured) + replay.Forge61.verify_batch(root, label, documents, definitions) + changed = copy.deepcopy(documents) + value = n.parse_nbt(changed["E-03"]["snapshot"][0]["nbt"]) + value["Items"][0]["count"] = n.Int(3) + changed["E-03"]["snapshot"][0]["nbt"] = value.snbt() + with self.assertRaisesRegex(AssertionError, "replay disagrees"): + replay.Forge61.verify_batch(root, "cycle-030", changed, definitions) + + def test_freeze_copies_real_stopped_files_and_seal_detects_change(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + run = root / "run" + for mode in replay.MODES: + stopped_fixture(run / mode) + put_json(run / mode / "advantage-works-results/W-01-checkpoint-setup.json", + dict(recordedAtUtc="2026-11-11T18:00:00+00:00")) + put_json(run / replay.RECEIPT, complete_report()) + source = root / "candidate.json" + put_json(source, {"fixture": True}) + files = {str(path): replay.sha(path.read_bytes()) for path in replay.oracle_paths()} + files[str(source)] = replay.sha(source.read_bytes()) + seal = root / "seal.json" + put_json(seal, dict(format=1, purpose="NATIVE61_ENDURANCE_PRE_RUN_SEAL", files=files, + oracles=[str(path) for path in replay.oracle_paths()], + createdAtUtc="2026-11-11T17:59:00+00:00")) + pin = replay.sha(seal.read_bytes()) + before = replay.tree(run, replay.Budget()) + frozen = root / "frozen" + result = replay.freeze(run, seal, pin, replay.sha((run / replay.RECEIPT).read_bytes()), frozen, replay.Budget()) + self.assertEqual(result["status"], "PASS") + self.assertEqual(replay.tree(frozen / "run", replay.Budget()), before) + self.assertEqual(replay.tree(run, replay.Budget()), before) + source.write_text("changed", encoding="utf-8") + with self.assertRaisesRegex(AssertionError, "pin"): + replay.verify_seal(seal, pin, replay.Budget()) + + def test_late_seal_fails_closed_but_explicit_retrospective_cannot_claim_custody(self): + with tempfile.TemporaryDirectory() as directory: + run = Path(directory) + for mode in replay.MODES: + put_json(run / mode / "advantage-works-results/W-01-checkpoint-setup.json", + dict(recordedAtUtc="2026-11-11T18:28:07Z")) + sealed = dict(purpose="NATIVE61_ENDURANCE_PRE_RUN_SEAL", createdAtUtc="2026-11-11T18:41:34+00:00") + with self.assertRaisesRegex(AssertionError, "not made before"): + replay.custody(sealed, run, replay.Budget()) + sealed["purpose"] = "NATIVE61_ENDURANCE_RETROSPECTIVE_SEAL" + actual = replay.custody(sealed, run, replay.Budget()) + self.assertIs(actual["preRunCustody"], False) + self.assertIn("No pre-run", actual["coverageGap"]) + sealed["createdAtUtc"] = "2026-11-11T18:20:00+00:00" + self.assertIs(replay.custody(sealed, run, replay.Budget())["preRunCustody"], False) + + def test_raw_stopped_data_version_4671_and_components(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + stopped_fixture(root) + before = replay.tree(root, replay.Budget()) + actual = replay.raw_final(root, replay.Budget()) + self.assertEqual(actual, dict(status="PASS", dataVersion=4671, comparedRows=16, comparedChunks=1)) + self.assertEqual(replay.tree(root, replay.Budget()), before) + + def test_raw_component_value_or_type_loss_rejected(self): + for replacement in (n.Float(99), n.Int(100)): + with self.subTest(replacement=replacement), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + world = stopped_fixture(root) + raw = chunk() + raw["block_entities"][0]["Items"][0]["components"]["minecraft:custom_data"]["energy"] = replacement + (world / "region/r.0.0.mca").write_bytes(region(payload(raw))) + with self.assertRaisesRegex(AssertionError, "component mismatch"): + replay.raw_final(root, replay.Budget()) + + def test_raw_unknown_component_loss_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + world = stopped_fixture(root) + raw = chunk() + raw["block_entities"][0].pop("components") + (world / "region/r.0.0.mca").write_bytes(region(payload(raw))) + with self.assertRaisesRegex(AssertionError, "component mismatch"): + replay.raw_final(root, replay.Budget()) + + def test_old_or_wrongly_typed_data_version_rejected(self): + for version in (n.Int(2730), n.Int(3465), n.Int(3955), n.Long(4671)): + with self.subTest(version=version): + root = chunk() + root["DataVersion"] = version + with self.assertRaisesRegex(AssertionError, "4671"): + replay.terrain(payload(root), (0, 0)) + + def test_wrong_level_data_version_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + world = stopped_fixture(root) + (world / "level.dat").write_bytes(gzip.compress(encoded(n.Compound(Data=n.Compound(DataVersion=n.Int(3465)))))) + with self.assertRaisesRegex(AssertionError, "level DataVersion"): + replay.raw_final(root, replay.Budget()) + + def test_compression_formats_and_coordinate_guard(self): + for compression in (1, 2, 3): + with self.subTest(compression=compression): + sections, tiles = replay.terrain(payload(chunk(), compression), (0, 0)) + self.assertEqual(sections[0][0]["Name"], "basepower:test") + self.assertEqual(tiles[1, 1, 1]["Items"][0]["count"], 1) + with self.assertRaisesRegex(AssertionError, "4671"): + replay.terrain(payload(chunk()), (1, 0)) + + def test_duplicate_section_or_tile_rejected(self): + for key in ("sections", "block_entities"): + with self.subTest(key=key): + root = chunk() + root[key].append(copy.deepcopy(root[key][0])) + with self.assertRaisesRegex(AssertionError, "Duplicate"): + replay.terrain(payload(root), (0, 0)) + + def test_packed_missing_wrong_marker_rejected(self): + for marker in (None, n.Byte(1), n.Int(0)): + with self.subTest(marker=marker): + root = chunk() + if marker is None: + root["block_entities"][0].pop("keepPacked") + else: + root["block_entities"][0]["keepPacked"] = marker + with self.assertRaisesRegex(AssertionError, "unpacked"): + replay.terrain(payload(root), (0, 0)) + + def test_all_native_stack_type_negative_controls(self): + for key, value in (("count", n.Byte(1)), ("count", n.Int(0)), ("count", n.Int(65)), + ("Slot", n.Int(0)), ("id", n.String("not_namespaced")), + ("components", n.String("lost")), ("Count", n.Byte(1)), ("tag", n.Compound())): + with self.subTest(key=key, value=value): + root = tile() + root["Items"][0][key] = value + with self.assertRaisesRegex(AssertionError, "native count"): + replay.row_nbt({"nbt": root.snbt()}) + + def test_duplicate_slots_and_snbt_keys_rejected(self): + root = tile() + root["Items"].append(copy.deepcopy(root["Items"][0])) + with self.assertRaisesRegex(AssertionError, "duplicate slot"): + replay.row_nbt({"nbt": root.snbt()}) + for text in ('{x:1,x:2}', '{outer:{"x":1,x:2}}'): + with self.subTest(text=text), self.assertRaisesRegex(ValueError, "Duplicate SNBT"): + replay.row_nbt({"nbt": text}) + + def test_snbt_trailing_or_ambiguous_nbt_rejected(self): + with self.assertRaises(ValueError): + replay.row_nbt({"nbt": "{} trailing"}) + with self.assertRaisesRegex(AssertionError, "Ambiguous"): + replay.row_nbt({"nbt": "{}", "nbtBase64": "AA=="}) + + def test_binary_checkpoint_preserves_native_components(self): + data = base64.b64encode(gzip.compress(encoded(tile()))).decode("ascii") + self.assertEqual(replay.typed_nbt(replay.row_nbt({"nbtBase64": data})), replay.typed_nbt(tile())) + + def test_duplicate_checkpoint_position_rejected(self): + value = document() + value["snapshot"] *= 2 + with self.assertRaisesRegex(AssertionError, "Duplicate"): + replay.snapshot(value) + + def test_compressed_bomb_trailing_and_external_chunks_rejected(self): + for data, compression in ((zlib.compress(b"x" * (16 * 1024 * 1024 + 1)), 2), + (gzip.compress(encoded(chunk())) + b"trailing", 1), (b"anything", 130)): + with self.subTest(compression=compression), self.assertRaises(ValueError): + replay._decompress(data, compression) + + def test_duplicate_binary_keys_or_trailing_nbt_rejected(self): + binary = encoded(n.Compound(x=n.Int(1))) + duplicate = binary[:-1] + binary[3:-1] + b"\x00" + for data in (duplicate, binary + b"\x00"): + with self.subTest(data=data), self.assertRaises(ValueError): + replay._nbt(data) + + def test_overlapping_sector_allocation_rejected(self): + data = bytearray(region(payload(chunk()))) + data[4:8] = data[:4] + with self.assertRaisesRegex(ValueError, "Overlapping"): + list(replay._payloads(bytes(data), 0, 0)) + + def test_json_duplicates_nonfinite_and_size_bound(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + for text in ('{"status":"PASS","status":"FAIL"}', '{"value":NaN}'): + path.write_text(text, encoding="utf-8") + with self.assertRaises(ValueError): + replay.Budget().json(path) + path.write_bytes(b"0123456789") + with self.assertRaisesRegex(ValueError, "bound"): + replay.Budget().read(path, 3) + + def test_deadline_and_total_byte_bound(self): + budget = replay.Budget() + budget.deadline = 0 + with self.assertRaises(TimeoutError): + budget.check() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "one" + path.write_bytes(b"x") + budget = replay.Budget() + budget.bytes = replay.MAX_TOTAL + with self.assertRaisesRegex(ValueError, "bound"): + budget.read(path) + + def test_new_output_disjoint_and_no_overwrite(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + original = root / "original" + replay.write_new(original, {"status": "HELD"}) + with self.assertRaises(FileExistsError): + replay.write_new(original, {"status": "PASS"}) + self.assertEqual(json.loads(original.read_text()), {"status": "HELD"}) + with self.assertRaises(ValueError): + replay.disjoint(root / "child", root) + + def test_held_smoke_running_fail_and_bool_duration_rejected(self): + for status in ("HELD", "FAIL", "RUNNING", "SMOKE_PASS_NOT_ENDURANCE"): + report = complete_report() + report["status"] = status + with self.subTest(status=status), self.assertRaisesRegex(AssertionError, "complete"): + replay.complete(report) + report = complete_report() + report["duration_seconds"] = True + with self.assertRaisesRegex(AssertionError, "complete"): + replay.complete(report) + report = complete_report() + replay.complete(report) + for key, value in (("game_ticks", 32399), ("game_ticks", True), ("verified_checkpoints", 32), + ("endurance_batches", 29), ("elapsed_seconds", 1799)): + changed = copy.deepcopy(report) + changed["profiles"][replay.MODES[0]][key] = value + with self.subTest(key=key), self.assertRaisesRegex(AssertionError, "Incomplete"): + replay.complete(changed) + + def test_external_pin_tampering_and_path_alias_rejected(self): + replay.pin(b"frozen", replay.sha(b"frozen")) + with self.assertRaisesRegex(AssertionError, "pin"): + replay.pin(b"changed", replay.sha(b"frozen")) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "target").write_bytes(b"x") + replay.os.link(root / "target", root / "alias") + with self.assertRaisesRegex(ValueError, "Alias"): + replay.tree(root, replay.Budget()) + + def test_stop_markers_and_real_session_lock(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + stopped_fixture(root) + before = (root / replay.WORLD / "session.lock").read_bytes() + with replay.stopped(root, replay.Budget()): + if replay.os.name == "nt": + with self.assertRaises(OSError): + with replay.stopped(root, replay.Budget()): + self.fail("Active lock admitted") + self.assertEqual((root / replay.WORLD / "session.lock").read_bytes(), before) + (root / "console-endurance-restart.log").write_text("Still running", encoding="utf-8") + with self.assertRaisesRegex(AssertionError, "shutdown"): + with replay.stopped(root, replay.Budget()): + self.fail("Running world admitted") + + def test_feed_acknowledgements_and_rebuild_negative_control(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for boot, count in zip(("endurance", "endurance-restart"), replay.expected_inventory_acknowledgements(30)): + (root / f"console-{boot}.log").write_text("Replaced a slot at 1, 1, 1\n" * count, encoding="utf-8") + self.assertEqual(replay.feed_audit(root, replay.Budget())["status"], "PASS") + path = root / "console-endurance-restart.log" + path.write_text(path.read_text() + "Advantage Works built", encoding="utf-8") + with self.assertRaisesRegex(AssertionError, "rebuilt"): + replay.feed_audit(root, replay.Budget()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_endurance_stop.py b/test-rig/migration/test_native12111_endurance_stop.py new file mode 100644 index 00000000..fc2976d9 --- /dev/null +++ b/test-rig/migration/test_native12111_endurance_stop.py @@ -0,0 +1,44 @@ +import unittest +from unittest.mock import Mock, call + +from native12111_endurance import stop_at_checkpoint + + +class EnduranceStoppedCheckpointTest(unittest.TestCase): + def test_freezes_before_observation_and_keeps_frozen_until_saved_and_stopped(self): + rig = Mock() + rig.structure.return_value = {"W-01": "MANUAL_REQUIRED"} + self.assertEqual({"W-01": "MANUAL_REQUIRED"}, stop_at_checkpoint(rig)) + self.assertEqual([ + call.command("tick freeze", "The game is frozen"), + call.checkpoint("restart-resumed"), + call.batch("restart-resumed"), + call.structure(), + call.command("save-all flush", "Saved the game"), + call.shutdown(), + ], rig.mock_calls) + + def test_failed_tick_gate_cannot_produce_a_checkpoint_or_successful_save(self): + rig = Mock() + rig.command.side_effect = RuntimeError("tick gate refused") + with self.assertRaisesRegex(RuntimeError, "tick gate refused"): + stop_at_checkpoint(rig) + rig.checkpoint.assert_not_called() + rig.batch.assert_not_called() + rig.structure.assert_not_called() + rig.shutdown.assert_not_called() + + def test_failed_measurement_cannot_be_reported_as_a_completed_save(self): + rig = Mock() + rig.batch.side_effect = AssertionError("wrong item total") + with self.assertRaisesRegex(AssertionError, "wrong item total"): + stop_at_checkpoint(rig) + self.assertEqual([ + call.command("tick freeze", "The game is frozen"), + call.checkpoint("restart-resumed"), + call.batch("restart-resumed"), + ], rig.mock_calls) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_family_policy.py b/test-rig/migration/test_native12111_family_policy.py new file mode 100644 index 00000000..68922205 --- /dev/null +++ b/test-rig/migration/test_native12111_family_policy.py @@ -0,0 +1,151 @@ +"""Materialized family profile policy; real jar/hash/manifest checks, no launch.""" + +import copy +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from zipfile import ZipFile + +import native12111 as native + + +class Native12111FamilyPolicyTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="native61-family-policy-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name).resolve() + self.seed = self.root / "seed" + self.seed.mkdir() + code = self.seed / "unit-code" + code.write_bytes(b"unit boundary: official seed custody is tested separately") + self.inventory = dict(forge=native.FORGE_VERSION, files={ + code.name: dict(sha256=native.digest(code), size=code.stat().st_size)}) + self.inventory_file = self.root / "inventory.json" + self.inventory_file.write_text(json.dumps(self.inventory), encoding="utf-8") + self.profile = self.root / "family.json" + self.document = dict(mode="packaged", minecraftVersion=native.MINECRAFT, + forge=dict(version=native.FORGE_VERSION), qualificationStatus=native.EXPLORATORY, mods=[]) + for identity, version in (("basepower", "2.4.15.121111"), ("basesteam", "2.2.16.121111"), + ("baseelectric", "2.2.16.121111"), ("basemetals", "3.1.2.121111"), + ("orespawn", "4.1.0.121111"), ("advantageworkstest", "1.1.5.121111")): + self.add_jar(identity, version) + self.save() + + def add_jar(self, identity, version): + path = self.root / (identity + "-" + version + ".jar") + self.write_jar(path, identity, version) + row = dict(id=identity, version=version, path=str(path), file=path.name, sha256=native.digest(path)) + self.document["mods"].append(row) + return row + + def write_jar(self, path, identity, version, *, power_sha=None): + with ZipFile(path, "w") as jar: + jar.writestr("META-INF/mods.toml", 'modLoader="javafml"\nloaderVersion="[61,)"\n' + 'license="LGPL-3.0"\n[[mods]]\nmodId="' + identity + '"\nversion="' + version + '"\n') + if identity == "advantageworkstest": + power = next(row["sha256"] for row in self.document["mods"] if row["id"] == "basepower") + jar.writestr("META-INF/MANIFEST.MF", "Manifest-Version: 1.0\nBuilt-On: " + native.FORGE_VERSION + + "\nPower-Input-Status: " + native.EXPLORATORY + "\nPower-Input-SHA256: " + + (power_sha or power) + "\n") + + def save(self): + self.profile.write_text(json.dumps(self.document), encoding="utf-8") + + def read(self, *, observed_inventory=None, inventory_sha=None): + # Isolate only the official runtime seed boundary, not artifact or profile validation. + with patch.object(native, "inspect_seed", return_value=( + self.inventory if observed_inventory is None else observed_inventory)), \ + patch.object(native, "stage") as stage, patch.object(native, "java21") as java: + result = native.read_family_target(self.profile, self.seed, self.inventory_file, + inventory_sha or native.digest(self.inventory_file)) + stage.assert_not_called() + java.assert_not_called() + return result + + def test_valid_six_jar_profile_passes_exact_checks_without_writes(self): + before = {path: path.read_bytes() for path in self.root.rglob("*") if path.is_file()} + seed, inventory, mods = self.read() + self.assertEqual(seed, self.seed) + self.assertEqual(inventory, self.inventory) + self.assertEqual(set(mods), {"basepower", "basesteam", "baseelectric", "basemetals", "orespawn", "advantageworkstest"}) + for row in self.document["mods"]: + self.assertEqual(mods[row["id"]], (row["version"], Path(row["path"]), row["sha256"])) + self.assertEqual(before, {path: path.read_bytes() for path in self.root.rglob("*") if path.is_file()}) + + def test_basemetals_missing_is_rejected_not_a_normal_supported_profile(self): + self.document["mods"] = [row for row in self.document["mods"] if row["id"] != "basemetals"] + self.save() + before = self.profile.read_bytes() + with self.assertRaisesRegex(ValueError, "BaseMetals"): + self.read() + self.assertEqual(self.profile.read_bytes(), before) + + def test_every_family_requirement_remains_required(self): + original = copy.deepcopy(self.document) + for identity in ("basepower", "basesteam", "baseelectric", "basemetals", "orespawn", "advantageworkstest"): + with self.subTest(missing=identity): + self.document = copy.deepcopy(original) + self.document["mods"] = [row for row in self.document["mods"] if row["id"] != identity] + self.save() + with self.assertRaisesRegex(ValueError, "family profile"): + self.read() + + def test_additional_pinned_provider_does_not_replace_basemetals(self): + self.add_jar("mineralogy", "6.2.0.121111") + self.save() + self.assertEqual(len(self.read()[2]), 7) + self.document["mods"] = [row for row in self.document["mods"] if row["id"] != "basemetals"] + self.save() + with self.assertRaisesRegex(ValueError, "BaseMetals"): + self.read() + + def test_basemetals_sha_tamper_is_not_accepted(self): + row = next(row for row in self.document["mods"] if row["id"] == "basemetals") + with Path(row["path"]).open("ab") as jar: + jar.write(b"changed bytes") + with self.assertRaisesRegex(AssertionError, "artifact hash"): + self.read() + + def test_basemetals_mod_identity_cannot_be_spoofed_by_a_valid_sha(self): + row = next(row for row in self.document["mods"] if row["id"] == "basemetals") + self.write_jar(Path(row["path"]), "othermod", row["version"]) + row["sha256"] = native.digest(Path(row["path"])) + self.save() + with self.assertRaisesRegex(ValueError, "identity/version"): + self.read() + + def test_older_basemetals_version_is_rejected(self): + row = next(row for row in self.document["mods"] if row["id"] == "basemetals") + row["version"] = "3.1.2.121011" + self.save() + with self.assertRaisesRegex(ValueError, "ordinary jar pin"): + self.read() + + def test_duplicate_basemetals_identity_is_rejected(self): + self.document["mods"].append(copy.deepcopy(next( + row for row in self.document["mods"] if row["id"] == "basemetals"))) + self.save() + with self.assertRaisesRegex(ValueError, "ordinary jar pin"): + self.read() + + def test_required_basemetals_does_not_bypass_harness_compile_pin(self): + row = next(row for row in self.document["mods"] if row["id"] == "advantageworkstest") + self.write_jar(Path(row["path"]), row["id"], row["version"], power_sha="F" * 64) + row["sha256"] = native.digest(Path(row["path"])) + self.save() + with self.assertRaisesRegex(AssertionError, "Harness compile pin"): + self.read() + + def test_required_basemetals_does_not_bypass_inventory_hash_or_seed_custody(self): + with self.assertRaisesRegex(AssertionError, "artifact hash"): + self.read(inventory_sha="F" * 64) + changed = copy.deepcopy(self.inventory) + changed["files"] = {} + with self.assertRaisesRegex(AssertionError, "sealed code inventory"): + self.read(observed_inventory=changed) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_launch_environment.py b/test-rig/migration/test_native12111_launch_environment.py new file mode 100644 index 00000000..d74d133b --- /dev/null +++ b/test-rig/migration/test_native12111_launch_environment.py @@ -0,0 +1,249 @@ +import contextlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +from matrix import digest +import native117_code as engine +import native12111 as native + + +JVM_OPTIONS = ("JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS") + + +class Native61LaunchEnvironmentTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.runtime = self.root / "runtime with spaces" + self.runtime.mkdir() + self.java = self.root / "java.exe" + self.environment = {key: value for key, value in os.environ.items() if key not in JVM_OPTIONS} + self.environment["NATIVE_ENV_TEST"] = "preserved" + self.manifest = self.root / "mixin-export.json" + + @contextlib.contextmanager + def launch_fixture(self, server_type=native.Server, environment=None, failure=None): + logging = self.runtime / ("libraries/net/minecraftforge/fmlloader/" + server_type.forge_version + + "/fmlloader-" + server_type.forge_version + ".jar") + logging.parent.mkdir(parents=True, exist_ok=True) + logging.touch() + with (patch.dict(os.environ, self.environment if environment is None else environment, clear=True), + patch.object(engine.subprocess, "Popen", return_value=Mock(pid=61)) as popen, + patch.object(engine.threading, "Thread"), + patch.object(engine.NativeLaunch, "wait", side_effect=failure) as wait, + patch.object(server_type, "logging_configuration", return_value="fixture://log4j") as configure): + yield popen, configure, wait + + def construct(self, server_type=native.Server, **kwargs): + server = server_type(self.runtime, self.java, native.LAUNCHER, "boot", **kwargs) + self.addCleanup(server.log.close) + return server + + def sealed_manifest(self, **changes): + document = dict(format=1, purpose="NATIVE61_MIXIN_EXPORT_DIAGNOSTIC", + runtime=str(self.runtime.resolve()), label="boot", + properties={"mixin.debug.export": True}) + document.update(changes) + self.manifest.write_text(json.dumps(document), encoding="utf-8") + return dict(diagnostic_environment=self.manifest, diagnostic_environment_sha256=digest(self.manifest)) + + def receipt(self): + return self.runtime / "native61-launch-environment-boot.json" + + def assert_refused(self, kwargs=None, environment=None, error=ValueError): + with self.launch_fixture(environment=environment) as (popen, configure, _): + with self.assertRaises(error): + self.construct(**(kwargs or {})) + popen.assert_not_called() + configure.assert_not_called() + self.assertFalse((self.runtime / "console-boot.log").exists()) + + def test_legacy_launcher_keeps_implicit_environment_inheritance(self): + environment = dict(self.environment, JAVA_TOOL_OPTIONS="-Dlegacy.example=true") + with self.launch_fixture(engine.NativeLaunch, environment) as (popen, _, _): + self.construct(engine.NativeLaunch) + self.assertIsNone(popen.call_args.kwargs.get("env")) + + def test_optional_engine_hook_passes_an_explicit_child_environment(self): + class ExplicitLaunch(engine.NativeLaunch): + def launch_environment(self): + return {"EXPLICIT_HOOK": "called"} + with self.launch_fixture(ExplicitLaunch) as (popen, _, _): + self.construct(ExplicitLaunch) + self.assertEqual({"EXPLICIT_HOOK": "called"}, popen.call_args.kwargs.get("env")) + + def test_engine_hook_failure_precedes_logging_and_process_creation(self): + class RefusingLaunch(engine.NativeLaunch): + def launch_environment(self): + raise ValueError("deliberate environment refusal") + with self.launch_fixture(RefusingLaunch) as (popen, configure, _): + with self.assertRaisesRegex(ValueError, "environment refusal"): + self.construct(RefusingLaunch) + popen.assert_not_called() + configure.assert_not_called() + self.assertFalse((self.runtime / "console-boot.log").exists()) + + def test_native61_passes_a_snapshot_without_mutating_or_dropping_parent_values(self): + with self.launch_fixture() as (popen, _, _): + before = dict(os.environ) + self.construct() + self.assertTrue(before == dict(os.environ), "Parent environment changed") + child = popen.call_args.kwargs.get("env") + self.assertTrue(self.environment == child, "Native61 child environment differs from the approved snapshot") + child["CHILD_ONLY"] = "separate" + self.assertNotIn("CHILD_ONLY", self.environment) + + def test_all_inherited_jvm_options_fail_closed_including_mixin_export(self): + for variable in JVM_OPTIONS: + for value in ("-javaagent:outside.jar", "-agentpath:outside.dll", "-agentlib:jdwp", + "@outside.args", "-XX:ActiveProcessorCount=4", "-Dmixin.debug.export=true"): + with self.subTest(variable=variable, value=value): + self.assert_refused(environment=dict(self.environment, **{variable: value})) + self.assertFalse(self.receipt().exists()) + + def test_blank_jvm_options_are_preserved_like_the_java_runner(self): + environment = dict(self.environment, **{variable: " " for variable in JVM_OPTIONS}) + with self.launch_fixture(environment=environment) as (popen, _, _): + self.construct() + self.assertTrue(environment == popen.call_args.kwargs.get("env"), "Blank JVM option values were not preserved") + + def test_java_version_probe_also_rejects_inherited_options_before_execution(self): + self.java.touch() + for variable in JVM_OPTIONS: + with (self.subTest(variable=variable), + patch.dict(os.environ, dict(self.environment, **{variable: "-javaagent:outside.jar"}), clear=True), + patch.object(native.subprocess, "run") as run): + with self.assertRaisesRegex(ValueError, variable): + native.java21(self.java) + run.assert_not_called() + + def test_java_version_probe_uses_an_approved_snapshot_and_keeps_its_version_gate(self): + self.java.touch() + result = Mock(stdout="", stderr='openjdk version "21.0.7"') + with (patch.dict(os.environ, self.environment, clear=True), + patch.object(native.subprocess, "run", return_value=result) as run): + self.assertEqual((self.java, result.stderr), native.java21(self.java)) + self.assertTrue(self.environment == run.call_args.kwargs["env"], "Version probe environment changed") + result.stderr = 'openjdk version "17.0.1"' + with self.assertRaisesRegex(ValueError, "requires Java 21"): + native.java21(self.java) + + def test_checksum_sealed_export_is_explicit_and_diagnostic_not_qualified(self): + kwargs = self.sealed_manifest() + with self.launch_fixture() as (popen, _, _): + before = dict(os.environ) + self.construct(**kwargs) + self.assertTrue(before == dict(os.environ), "Diagnostic launch changed the parent environment") + expected = dict(self.environment, JAVA_TOOL_OPTIONS="-Dmixin.debug.export=true") + self.assertTrue(expected == popen.call_args.kwargs["env"], "Diagnostic child environment differs from the seal") + receipt = json.loads(self.receipt().read_text()) + self.assertEqual("DIAGNOSTIC_NOT_QUALIFIED", receipt["status"]) + self.assertFalse(receipt["qualificationAllowed"]) + self.assertEqual(kwargs["diagnostic_environment_sha256"], receipt["manifestSha256"]) + self.assertEqual({"JAVA_TOOL_OPTIONS": "-Dmixin.debug.export=true"}, receipt["environment"]) + + def test_export_filter_and_decompiler_controls_remain_available(self): + kwargs = self.sealed_manifest(properties={"mixin.debug.export": True, + "mixin.debug.export.decompile": False, + "mixin.debug.export.filter": "net.minecraft.**.SimpleRegionStorage$*"}) + with self.launch_fixture() as (popen, _, _): + self.construct(**kwargs) + self.assertEqual("-Dmixin.debug.export=true -Dmixin.debug.export.decompile=false " + "-Dmixin.debug.export.filter=net.minecraft.**.SimpleRegionStorage$*", + popen.call_args.kwargs["env"]["JAVA_TOOL_OPTIONS"]) + + def test_export_file_alone_cannot_enable_inherited_options(self): + self.sealed_manifest() + self.assert_refused(environment=dict(self.environment, JAVA_TOOL_OPTIONS="-Dmixin.debug.export=true")) + + def test_opt_in_requires_both_explicit_arguments(self): + kwargs = self.sealed_manifest() + for partial in ({"diagnostic_environment": self.manifest}, + {"diagnostic_environment_sha256": kwargs["diagnostic_environment_sha256"]}): + with self.subTest(partial=partial): + self.assert_refused(partial) + + def test_bad_or_changed_checksum_is_rejected_before_launch(self): + kwargs = self.sealed_manifest() + for sha in ("", "0" * 64, "not-a-hash"): + with self.subTest(sha=sha): + self.assert_refused(dict(kwargs, diagnostic_environment_sha256=sha)) + self.manifest.write_text(self.manifest.read_text() + " ") + self.assert_refused(kwargs) + + def test_seal_is_bound_to_this_runtime_label_and_purpose(self): + for change in ({"format": True}, {"format": 2}, {"purpose": "QUALIFIED"}, + {"runtime": str(self.root)}, {"label": "another-boot"}, {"unexpected": True}): + with self.subTest(change=change): + self.assert_refused(self.sealed_manifest(**change)) + + def test_seal_cannot_add_agents_other_properties_or_argument_files(self): + for properties in ({"mixin.debug.export": False}, {"mixin.debug.export": "true"}, + {"mixin.debug.export": True, "javaagent": "outside.jar"}, + {"mixin.debug.export": True, "other.setting": True}, + {"mixin.debug.export": True, "mixin.debug.export.decompile": "false"}, + {"mixin.debug.export": True, "mixin.debug.export.filter": "* -javaagent:outside.jar"}, + {"mixin.debug.export": True, "mixin.debug.export.filter": "@outside.args"}): + with self.subTest(properties=properties): + self.assert_refused(self.sealed_manifest(properties=properties)) + + def test_duplicate_fields_malformed_and_oversized_seals_are_rejected(self): + kwargs = self.sealed_manifest() + valid = self.manifest.read_text() + for payload in (valid.replace('"format": 1', '"format": 1, "format": 1'), + valid.replace('"mixin.debug.export": true', '"mixin.debug.export": true, "mixin.debug.export": true'), + "[]", "{", " " * (16384 + 1)): + with self.subTest(payload=payload[:80]): + self.manifest.write_text(payload) + self.assert_refused(dict(kwargs, diagnostic_environment_sha256=digest(self.manifest))) + + def test_sealed_diagnostic_does_not_excuse_inherited_agent_or_other_options(self): + kwargs = self.sealed_manifest() + for variable in JVM_OPTIONS: + with self.subTest(variable=variable): + self.assert_refused(kwargs, environment=dict(self.environment, **{variable: "-javaagent:outside.jar"})) + + def test_existing_diagnostic_receipt_is_not_overwritten(self): + kwargs = self.sealed_manifest() + self.receipt().write_text("retained earlier receipt") + self.assert_refused(kwargs, error=FileExistsError) + self.assertEqual("retained earlier receipt", self.receipt().read_text()) + + def test_startup_failure_still_captures_diagnostics_and_preserves_primary_error(self): + kwargs = self.sealed_manifest() + failure = TimeoutError("original startup deadline") + cleanup = RuntimeError("cleanup failure") + def close(server): + server.log.close() + raise cleanup + with (self.launch_fixture(failure=failure) as (popen, _, _), + patch.object(native.Server, "close", autospec=True, side_effect=close), + patch.object(engine.startup_diagnostics, "observe", return_value={}) as observe, + patch.object(engine.startup_diagnostics, "retain") as retain): + with self.assertRaises(TimeoutError) as caught: + self.construct(**kwargs) + self.assertIs(failure, caught.exception) + self.assertEqual(1, popen.call_count) + observe.assert_called_once() + self.assertIs(cleanup, retain.call_args.args[3]) + self.assertEqual("DIAGNOSTIC_NOT_QUALIFIED", json.loads(self.receipt().read_text())["status"]) + + def test_environment_snapshot_works_in_a_real_small_child_process(self): + with patch.dict(os.environ, self.environment, clear=True): + environment = native.native_launch_environment(self.runtime, "boot") + child = subprocess.run([sys.executable, "-I", "-B", "-c", + "import json,os; print(json.dumps({k:os.environ.get(k) for k in " + "('JAVA_TOOL_OPTIONS','JDK_JAVA_OPTIONS','_JAVA_OPTIONS','NATIVE_ENV_TEST')}))"], + env=environment, check=True, capture_output=True, text=True, timeout=20) + self.assertEqual({**dict.fromkeys(JVM_OPTIONS), "NATIVE_ENV_TEST": "preserved"}, json.loads(child.stdout)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_machine_adapters.py b/test-rig/migration/test_native12111_machine_adapters.py new file mode 100644 index 00000000..f6dc65ff --- /dev/null +++ b/test-rig/migration/test_native12111_machine_adapters.py @@ -0,0 +1,68 @@ +"""Fail-closed checks for native saved terrain and the optional oil input pin.""" + +import io +from pathlib import Path +import unittest +from unittest.mock import Mock +import zlib + +import nbtlib + +from native12111_machines import LifecycleRun, MINERALOGY, oil_pins +from native12111_operations import modern_chunk + + +class MachineAdapters(unittest.TestCase): + def test_lifecycle_inventory_uses_native_custom_data_command(self): + runner = object.__new__(LifecycleRun) + runner.command = Mock() + runner.inventory([1, 2, 3], 0, 'minecraft:dirt{AdvWorksWitness:"S-MOVE-east"}', 7) + runner.command.assert_called_once_with( + 'item replace block 1 2 3 container.0 with ' + 'minecraft:dirt[minecraft:custom_data={AdvWorksWitness:"S-MOVE-east"}] 7') + + def test_oil_input_keeps_verified_source_and_rejects_another_build(self): + entry = (MINERALOGY["version"], Path("mineralogy.jar"), MINERALOGY["sha256"]) + pins = oil_pins({"mineralogy": entry}) + self.assertEqual(MINERALOGY["sourceRef"], pins["jars"][0]["sourceRef"]) + self.assertNotIn("sourceRef", oil_pins({"basepower": ("v", Path("p.jar"), "A" * 64)})["jars"][0]) + for version, sha in (("wrong", entry[2]), (entry[0], "B" * 64)): + with self.assertRaises(ValueError): + oil_pins({"mineralogy": (version, entry[1], sha)}) + + @staticmethod + def terrain(version=4671, legacy=False): + root = nbtlib.File({ + "DataVersion": nbtlib.Int(version), "xPos": nbtlib.Int(3), "zPos": nbtlib.Int(-2), + "sections": nbtlib.List[nbtlib.Compound]([nbtlib.Compound({ + "Y": nbtlib.Byte(4), "block_states": nbtlib.Compound({ + "palette": nbtlib.List[nbtlib.Compound]([nbtlib.Compound({ + "Name": nbtlib.String("basepower:fluid_container")})]), + "data": nbtlib.LongArray([17, -3])})})]), + "block_entities": nbtlib.List[nbtlib.Compound]([nbtlib.Compound({ + "id": nbtlib.String("basepower:fluid_container"), + "Tank": nbtlib.Compound({"Amount": nbtlib.Int(1234)})})]), + }) + if legacy: + root["Level"] = nbtlib.Compound() + raw = io.BytesIO() + root.write(raw) + compressed = b"\x02" + zlib.compress(raw.getvalue()) + return len(compressed).to_bytes(4, "big") + compressed + + def test_saved_chunk_accessors_preserve_typed_payload(self): + observed = modern_chunk(self.terrain()) + self.assertEqual(1234, int(observed["TileEntities"][0]["Tank"]["Amount"])) + self.assertIsInstance(observed["TileEntities"][0]["Tank"]["Amount"], nbtlib.Int) + self.assertEqual([17, -3], list(observed["Sections"][0]["BlockStates"])) + self.assertEqual("basepower:fluid_container", observed["Sections"][0]["Palette"][0]["Name"]) + self.assertEqual(-2, int(observed["zPos"])) + + def test_wrong_version_legacy_shape_and_broken_length_are_rejected(self): + for payload in (self.terrain(version=3839), self.terrain(legacy=True), self.terrain()[:-1]): + with self.subTest(payload=payload[:12]), self.assertRaises(ValueError): + modern_chunk(payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_oil_processing.py b/test-rig/migration/test_native12111_oil_processing.py new file mode 100644 index 00000000..a3c61ffc --- /dev/null +++ b/test-rig/migration/test_native12111_oil_processing.py @@ -0,0 +1,178 @@ +import copy +import json +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import native12111_oil_processing as processing + + +def fluid(name, amount): + return dict(FluidName=name, Amount=amount) if amount else {"Empty": 1} + + +def snapshot(phase): + cases = {} + for f in processing.fixtures(): + held, partial = phase in ("held", "intake"), phase == "partial" + drain = dict(Tank=fluid(f["fluid"], 0)) + if f["kind"] == "still": + raw, product = (1000, 0) if held else (700, 150) if partial else (0, 500) + machine = dict(InputTank=fluid(f["fluid"], raw), Tank=fluid("basepower:refined_oil", product), + Items=[dict(id="minecraft:coal", count=1, Slot=0)] if held else [], + burnTime=0 if held else 1000, totalBurnTime=0 if held else 1600) + else: + machine = dict(FuelTank=fluid(f["fluid"], 1000 if held else 900), + WaterTank=fluid("minecraft:water", 64 if phase == "held" else 0)) + if not held: + machine["BurnTimeTotal"] = 500 + if partial: + machine["BurnTime"] = 350 + rows = [] + for role, block in dict(source="minecraft:air", drain="basepower:fluid_drain", pipe="basepower:fluid_pipe", + machine=f["block"], gate="minecraft:redstone_block" if phase == "held" or + (phase == "intake" and f["kind"] == "still") else "minecraft:air").items(): + row = dict(pos=f[role], loaded=True, block=block) + if role in ("drain", "machine"): + row.update(tileIdentity=len(rows), nbt=json.dumps(drain if role == "drain" else machine)) + if role == "machine" and f["kind"] == "boiler": + row["energy"] = dict(steam=0 if held else 80 if partial else 74.375) + rows.append(row) + cases[f["id"]] = rows + return dict(minecraft=processing.native.MINECRAFT, forge=processing.native.FORGE, + station="lifecycle", worldTime=160, cases=cases) + + +class ProcessingOracleTest(unittest.TestCase): + def test_four_isolated_bounded_actual_bucket_circuits(self): + fixtures = processing.fixtures() + self.assertEqual(4, len(fixtures)) + self.assertEqual({"basepower-still", "mineralogy-still", "basepower-boiler", "mineralogy-boiler"}, + {f["id"] for f in fixtures}) + for f in fixtures: + self.assertEqual(f["fluid"] + "_bucket", f["bucket"]) + self.assertEqual(5, len({tuple(pos) for pos in f["witnesses"]})) + self.assertTrue(all(all(lo <= value <= hi for lo, value, hi in zip(f["min"], pos, f["max"])) + for pos in f["witnesses"])) + self.assertEqual(4, len({f["min"][0] for f in fixtures})) + + def test_all_native_processing_checkpoints(self): + for phase in ("intake", "held", "partial", "complete"): + with self.subTest(phase=phase): + self.assertEqual(4, len(processing.inspect(snapshot(phase), processing.fixtures(), phase))) + + def test_absent_or_malformed_tank_is_not_empty(self): + for value in ({}, {"Empty": 0}, {"Empty": 1, "Amount": 1000}, + {"FluidName": "basepower:crude_oil"}, {"FluidName": "basepower:crude_oil", "Amount": -1}, + {"FluidName": "basepower:crude_oil", "Amount": True}): + with self.subTest(value=value), self.assertRaises((AssertionError, KeyError)): + processing.tank(dict(Tank=value), "Tank", "basepower:crude_oil") + with self.assertRaises(KeyError): + processing.tank({}, "Tank", "basepower:crude_oil") + + def test_explicit_zero_debited_stack_keeps_identity_but_missing_is_never_zero(self): + self.assertEqual(0, processing.tank(dict(Tank=dict(FluidName="basepower:crude_oil", Amount=0)), + "Tank", "basepower:crude_oil")) + with self.assertRaises(AssertionError): + processing.tank(dict(Tank=dict(FluidName="mineralogy:crude_oil", Amount=0)), "Tank", "basepower:crude_oil") + document = snapshot("complete") + row = document["cases"]["basepower-still"][3] + tag = json.loads(row["nbt"]) + tag["InputTank"] = dict(FluidName="basepower:crude_oil", Amount=0) + row["nbt"] = json.dumps(tag) + processing.inspect(document, processing.fixtures(), "complete") + + def test_intake_has_no_boiler_redstone_or_water_and_water_charge_replaces_empty_flag(self): + runner = processing.ProcessingRun(None, None, None, processing.fixtures()) + commands, probes = [], [] + empty = snapshot("held") + for f in runner.definition: + row = empty["cases"][f["id"]][3] + tag = json.loads(row["nbt"]) + tag["InputTank" if f["kind"] == "still" else "FuelTank"] = {"Empty": 1} + row["nbt"] = json.dumps(tag) + runner.command = commands.append + runner.probe = lambda operation, label: probes.append((operation, label)) or empty + runner.build() + self.assertEqual(1, sum(operation == "place-buckets" for operation, _ in probes)) + self.assertFalse(any("data " in command for command in commands)) + for f in runner.definition: + gate = "setblock " + " ".join(map(str, f["gate"])) + self.assertIn(gate + (" minecraft:air" if f["kind"] == "boiler" else " minecraft:redstone_block"), commands) + commands.clear() + runner.charge_water_and_hold() + self.assertEqual(4, len(commands)) + self.assertEqual(2, sum(' WaterTank set value {FluidName:"minecraft:water",Amount:64}' in command + and command.startswith("data modify block ") for command in commands)) + self.assertFalse(any("data merge" in command for command in commands)) + + def test_readiness_uses_native_chunk_coordinates_not_block_coordinates(self): + runner = processing.ProcessingRun(Path("fixture-runtime"), None, None, processing.fixtures()) + runner.probe = lambda *args: None + server = SimpleNamespace(process=SimpleNamespace(pid=42), send=lambda *args: None, wait=lambda *args: None) + with patch.object(processing.native, "audit_staged"), patch.object(processing.native, "Server", return_value=server), \ + patch.object(processing, "await_ready") as ready: + runner.boot("processing-initial") + self.assertEqual(4, ready.call_count) + for call, f in zip(ready.call_args_list, runner.definition): + self.assertEqual([f["min"][0] >> 4, 128, f["max"][0] >> 4, 129], call.args[2]) + self.assertEqual(processing.native.MINECRAFT, call.kwargs["minecraft"]) + + def test_forged_identity_or_quantity_cannot_pass(self): + f = processing.fixtures()[0] + for key, tank in (("InputTank", fluid("mineralogy:crude_oil", 700)), + ("InputTank", fluid(f["fluid"], 699)), + ("Tank", fluid("basepower:refined_oil", 151))): + document = snapshot("partial") + row = document["cases"][f["id"]][3] + tag = json.loads(row["nbt"]) + tag[key] = tank + row["nbt"] = json.dumps(tag) + with self.subTest(key=key), self.assertRaises(AssertionError): + processing.inspect(document, processing.fixtures(), "partial") + + def test_water_fuel_energy_and_hold_negative_controls(self): + for phase, field, value in (("held", "FuelTank", fluid("mineralogy:crude_oil", 900)), + ("partial", "WaterTank", fluid("minecraft:water", 1)), + ("partial", "BurnTimeTotal", 499)): + document = snapshot(phase) + row = document["cases"]["mineralogy-boiler"][3] + tag = json.loads(row["nbt"]) + tag[field] = value + row["nbt"] = json.dumps(tag) + with self.subTest(field=field), self.assertRaises(AssertionError): + processing.inspect(document, processing.fixtures(), phase) + document = snapshot("partial") + document["cases"]["mineralogy-boiler"][3]["energy"]["steam"] = 79 + with self.assertRaises(AssertionError): + processing.inspect(document, processing.fixtures(), "partial") + + def test_missing_duplicate_unloaded_or_replaced_witness_fails(self): + for mutate in (lambda rows: rows.pop(), lambda rows: rows.append(copy.deepcopy(rows[0])), + lambda rows: rows[0].update(loaded=False), + lambda rows: rows[2].update(block="minecraft:air")): + document = snapshot("held") + mutate(document["cases"]["basepower-still"]) + with self.assertRaises(AssertionError): + processing.inspect(document, processing.fixtures(), "held") + before, after = snapshot("partial"), snapshot("complete") + after["cases"]["basepower-still"][3]["tileIdentity"] = 999 + with self.assertRaises(AssertionError): + processing.require_same_machines(before, after, processing.fixtures()) + + def test_idle_decay_is_exact_live_and_bounded_across_cold_chunk_load(self): + before = processing.inspect(snapshot("partial"), processing.fixtures(), "partial") + after = processing.inspect(snapshot("complete"), processing.fixtures(), "complete") + processing.require_decay(before, after, 440, exact=True) + after["basepower-boiler"]["steam"] += 0.0625 + with self.assertRaises(AssertionError): + processing.require_decay(before, after, 440, exact=True) + processing.require_decay(before, after, 440, exact=False) + after["basepower-boiler"]["steam"] = 80.0625 + with self.assertRaises(AssertionError): + processing.require_decay(before, after, 440, exact=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_operations_actor_load.py b/test-rig/migration/test_native12111_operations_actor_load.py new file mode 100644 index 00000000..cb2cd62e --- /dev/null +++ b/test-rig/migration/test_native12111_operations_actor_load.py @@ -0,0 +1,174 @@ +import copy +import unittest + +import nbtlib + +import native117_machine_operations as frozen +import native12111_operations as native + + +class NativeExpectedActorLoadTest(unittest.TestCase): + def documents(self, case_id="steam/extra-explosive-1", motion=(90.5, 0.383453342391226, 0), timestamp=101): + before = nbtlib.Compound({"id": nbtlib.String("minecraft:cow"), + "UUID": nbtlib.IntArray([0, 0, 0, 1]), + "NoAI": nbtlib.Byte(1), "NoGravity": nbtlib.Byte(1), + "Health": nbtlib.Float(995.2774), + "Motion": nbtlib.List[nbtlib.Double](motion), + "HurtByTimestamp": nbtlib.Int(timestamp)}) + after = copy.deepcopy(before) + after["Motion"] = nbtlib.List[nbtlib.Double](0.0 if abs(value) > 10 else value for value in motion) + after["HurtByTimestamp"] = nbtlib.Int(0) + + def case(tag): + return {"id": case_id, "positions": [], "entities": [ + {"uuid": "00000000-0000-0000-0000-000000000001", "nbt": tag.snbt()}]} + + return {"cases": [case(before)]}, {"cases": [case(after)], "held": True, "constructionCallsThisProcess": 0} + + def change(self, document, callback): + actor = document["cases"][0]["entities"][0] + tag = nbtlib.parse_nbt(actor["nbt"]) + callback(tag) + actor["nbt"] = tag.snbt() + + def compare(self, fixture, inspected): + original = copy.deepcopy((fixture, inspected)) + try: + return native.validate_restart(fixture, inspected) + finally: + self.assertEqual(original, (fixture, inspected), "Neither input may be rewritten") + + def test_only_native_adapter_accepts_the_proven_directional_transitions(self): + for case_id in ("steam/extra-explosive-0", "steam/extra-explosive-1"): + before, after = self.documents(case_id) + self.assertEqual("FAIL", frozen.validate_restart(before, after)["status"]) + result = self.compare(before, after) + self.assertEqual("PASS", result["status"]) + self.assertEqual({"Motion[0]", "HurtByTimestamp"}, { + row["field"] for change in result["expectedBeforeLoadTransformations"] for row in change["fields"]}) + self.assertEqual("FAIL", frozen.validate_restart(before, after)["status"]) + + def test_each_outlier_component_is_projected_and_boundary_components_are_exact(self): + for motion in ((10, -10, 0.5), (-90.5, 90.5, 11), (-10.001, 10.001, -11)): + before, after = self.documents(motion=motion, timestamp=0) + result = self.compare(before, after) + self.assertEqual("PASS", result["status"]) + fields = [field for row in result["expectedBeforeLoadTransformations"] for field in row["fields"]] + self.assertEqual(sum(abs(value) > 10 for value in motion), len(fields)) + + def test_in_range_velocity_corruption_fails(self): + for value in (10, -10, 0.5): + before, after = self.documents(motion=(value, 0, 0), timestamp=0) + self.change(after, lambda tag: tag["Motion"].__setitem__(0, nbtlib.Double(0))) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + def test_observed_motion_is_not_projected_even_when_raw_documents_match(self): + before, after = self.documents(timestamp=0) + after["cases"] = copy.deepcopy(before["cases"]) + self.assertEqual("PASS", frozen.validate_restart(before, after)["status"]) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + def test_observed_timestamp_is_not_projected(self): + before, after = self.documents(motion=(0, 0, 0)) + after["cases"] = copy.deepcopy(before["cases"]) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + def test_unexpected_nonzero_clamped_velocity_and_reversed_transition_fail(self): + before, after = self.documents(timestamp=0) + self.change(after, lambda tag: tag["Motion"].__setitem__(0, nbtlib.Double(1))) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + before, after = self.documents() + self.assertEqual("FAIL", self.compare({"cases": after["cases"]}, + dict(after, cases=before["cases"]))["status"]) + + def test_other_cases_and_entity_kinds_have_no_exception(self): + before, after = self.documents(case_id="steam/extra-recoil-1") + self.assertEqual("FAIL", self.compare(before, after)["status"]) + for field, value in (("id", nbtlib.String("minecraft:pig")), ("NoAI", nbtlib.Byte(0)), + ("NoGravity", nbtlib.Byte(0))): + before, after = self.documents() + for document in (before, after): + self.change(document, lambda tag: tag.__setitem__(field, value)) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + def test_relative_hurt_keys_and_attacker_references_are_not_projected(self): + for key in ("last_hurt_by_mob", "ticks_since_last_hurt_by_mob"): + before, after = self.documents() + for document in (before, after): + self.change(document, lambda tag: tag.__setitem__(key, nbtlib.Int(7))) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + before, after = self.documents() + self.change(after, lambda tag: tag.__setitem__("ticks_since_last_hurt_by_mob", nbtlib.Int(0))) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + def test_health_unknown_keys_position_and_nbt_type_changes_fail(self): + changes = (lambda tag: tag.__setitem__("Health", nbtlib.Float(1)), + lambda tag: tag.__setitem__("unknown", nbtlib.Int(7)), + lambda tag: tag.__delitem__("Health"), + lambda tag: tag.__setitem__("Health", nbtlib.Double(995.2774)), + lambda tag: tag.__setitem__("Pos", nbtlib.List[nbtlib.Double]([1, 2, 3])), + lambda tag: tag.__setitem__("Motion", nbtlib.List[nbtlib.Float]([0, 0, 0]))) + for change in changes: + before, after = self.documents() + self.change(after, change) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + def test_unknown_nested_payload_is_preserved_and_its_list_order_stays_exact(self): + before, after = self.documents() + payload = nbtlib.Compound({"opaque": nbtlib.IntArray([7, 8]), + "ordered": nbtlib.List[nbtlib.Int]([3, 2, 1])}) + for document in (before, after): + self.change(document, lambda tag: tag.__setitem__("private_payload", copy.deepcopy(payload))) + self.assertEqual("PASS", self.compare(before, after)["status"]) + self.change(after, lambda tag: tag["private_payload"].__setitem__( + "ordered", nbtlib.List[nbtlib.Int]([1, 2, 3]))) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + def test_exception_does_not_expand_to_nested_passenger_payloads(self): + before, after = self.documents() + passenger = nbtlib.Compound({"id": nbtlib.String("minecraft:pig"), + "Motion": nbtlib.List[nbtlib.Double]([90, 0, 0])}) + for document in (before, after): + self.change(document, lambda tag: tag.__setitem__( + "Passengers", nbtlib.List[nbtlib.Compound]([copy.deepcopy(passenger)]))) + self.change(after, lambda tag: tag["Passengers"][0]["Motion"].__setitem__(0, nbtlib.Double(0))) + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + def test_malformed_expected_motion_and_timestamp_fail_closed(self): + changes = (lambda tag: tag.__setitem__("Motion", nbtlib.List[nbtlib.Float]([90, 0, 0])), + lambda tag: tag.__setitem__("Motion", nbtlib.List[nbtlib.Double]([90, 0])), + lambda tag: tag.__delitem__("Motion"), + lambda tag: tag.__setitem__("HurtByTimestamp", nbtlib.Short(101)), + lambda tag: tag.__setitem__("HurtByTimestamp", nbtlib.Int(-1))) + for change in changes: + before, after = self.documents() + self.change(before, change) + with self.assertRaisesRegex(AssertionError, "Malformed native explosive actor"): + self.compare(before, after) + + def test_actor_uuid_and_set_changes_are_rejected(self): + before, after = self.documents() + self.change(after, lambda tag: tag.__setitem__("UUID", nbtlib.IntArray([0, 0, 0, 2]))) + with self.assertRaisesRegex(AssertionError, "metadata and saved UUID"): + self.compare(before, after) + before, after = self.documents() + after["cases"][0]["entities"].append(copy.deepcopy(after["cases"][0]["entities"][0])) + with self.assertRaisesRegex(AssertionError, "Duplicate"): + self.compare(before, after) + before, after = self.documents() + after["cases"][0]["entities"] = [] + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + def test_frozen_restart_and_block_position_gates_remain_in_force(self): + for key, value in (("held", False), ("constructionCallsThisProcess", 1)): + before, after = self.documents() + after[key] = value + with self.assertRaisesRegex(AssertionError, "Missing/rebuilt/unfrozen"): + self.compare(before, after) + before, after = self.documents() + after["cases"][0]["positions"] = [{"x": 1, "y": 2, "z": 3, "block": "minecraft:stone", "state": "stone"}] + self.assertEqual("FAIL", self.compare(before, after)["status"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_order_probe.py b/test-rig/migration/test_native12111_order_probe.py new file mode 100644 index 00000000..556ca689 --- /dev/null +++ b/test-rig/migration/test_native12111_order_probe.py @@ -0,0 +1,125 @@ +"""Fail-closed metadata/launch boundary controls, without launching Minecraft.""" + +import copy +import hashlib +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import native12111 as native +import native12111_order_probe as probe + + +def woven(reverse=False): + order = ["basepower", "basemetals"] + if reverse: + order.reverse() + return dict(status="PASS", scope=probe.SCOPE, qualificationAllowed=False, + regionHeads=order[:], regionReturns=[order[:], order[:]], worldHeads=order[:], worldgenHeads=order[:], + allHandlerBodiesMatchPinnedProduction=True, nativeReturnsCovered=2, exceptionEdgesChecked=True) + + +class NativeOrderProbeTest(unittest.TestCase): + def test_only_actual_reverse_permutations_pass(self): + self.assertEqual("PASS", probe.compare_orders(woven(), woven(True))["status"]) + first, second = woven(), woven() + first["mode"], second["mode"] = "power-low", "metals-low" + with self.assertRaisesRegex(AssertionError, "did not reverse"): + probe.compare_orders(first, second) + + def test_missing_duplicate_and_partial_order_vectors_fail(self): + for key in probe.VECTORS: + for value in (["basepower"], ["basepower", "basepower"], []): + actual = woven(True) + actual[key] = [value, value] if key == "regionReturns" else value + with self.subTest(key=key, value=value), self.assertRaises(AssertionError): + probe.compare_orders(woven(), actual) + actual = woven(True) + actual["regionReturns"].pop() + with self.assertRaises(AssertionError): + probe.compare_orders(woven(), actual) + + def test_unverified_bodies_missing_native_return_and_qualification_relabel_fail(self): + for key, value in (("allHandlerBodiesMatchPinnedProduction", False), ("nativeReturnsCovered", 1), + ("scope", "QUALIFIED"), ("qualificationAllowed", True), ("status", "FAIL"), ("exceptionEdgesChecked", False)): + actual = woven(True) + actual[key] = value + with self.subTest(key=key), self.assertRaises(AssertionError): + probe.compare_orders(woven(), actual) + + def test_plugin_requires_this_pid_runtime_mode_and_unprepared_state(self): + pins = dict(mode="power-low", priorities={"basepower.mixins.json": 900, "basemetals.mixins.json": 1100}) + runtime = Path(tempfile.gettempdir()).resolve() + before = {"basepower.mixins.json": 1000, "basemetals.mixins.json": 1000, "unrelated.json": 321} + record = dict(status=probe.SCOPE, qualificationAllowed=False, mode="power-low", runtime=str(runtime), + pid=123, preparedBefore=False, before=before, after={**before, **pins["priorities"]}) + probe.verify_plugin(record, pins, runtime, 123) + for key, value in (("pid", 124), ("runtime", "other"), ("mode", "metals-low"), + ("preparedBefore", True), ("qualificationAllowed", True), ("status", "PASS")): + bad = copy.deepcopy(record) + bad[key] = value + with self.subTest(key=key), self.assertRaises(AssertionError): + probe.verify_plugin(bad, pins, runtime, 123) + for position, key, value in (("before", "basepower.mixins.json", 900), + ("after", "basepower.mixins.json", 1000), ("after", "unrelated.json", 322)): + bad = copy.deepcopy(record) + bad[position][key] = value + with self.subTest(position=position, key=key), self.assertRaises(AssertionError): + probe.verify_plugin(bad, pins, runtime, 123) + + def test_plugin_missing_config_or_added_config_fails(self): + runtime = Path(tempfile.gettempdir()).resolve() + pins = dict(mode="power-low", priorities={"basepower.mixins.json": 900, "basemetals.mixins.json": 1100}) + record = dict(status=probe.SCOPE, qualificationAllowed=False, mode="power-low", runtime=str(runtime), + pid=123, preparedBefore=False, before={"basepower.mixins.json": 1000}, + after={"basepower.mixins.json": 900}) + with self.assertRaises(AssertionError): + probe.verify_plugin(record, pins, runtime, 123) + + def test_variant_preserves_all_six_original_mod_records(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + sidecar = root / "AdvantageOrderProbe-1.0.0.121111.jar" + sidecar.write_bytes(b"pinned-test-standin") + pin = dict(jar=str(sidecar), sha256=hashlib.sha256(sidecar.read_bytes()).hexdigest().upper()) + base = dict(mods=[dict(id=name, file=name + ".jar", sha256="source-" + name) for name in sorted(probe.MOD_IDS)]) + original = copy.deepcopy(base) + actual = probe.variant_profile(base, pin, root / "profile.json") + self.assertEqual(original, base) + self.assertEqual(original["mods"], actual["mods"][:6]) + self.assertEqual(probe.MOD_ID, actual["mods"][-1]["id"]) + sidecar.write_bytes(b"changed") + with self.assertRaises(AssertionError): + probe.variant_profile(base, pin, root / "bad.json") + for mods in (base["mods"][:-1], base["mods"] + [dict(id="unknown")]): + with self.assertRaises(AssertionError): + probe.variant_profile(dict(mods=mods), pin, root / "bad.json") + + def test_existing_diagnostic_api_and_markers_are_not_relaxed(self): + with tempfile.TemporaryDirectory() as folder, patch.dict(os.environ, {"JAVA_TOOL_OPTIONS": "", "JDK_JAVA_OPTIONS": "", "_JAVA_OPTIONS": ""}): + runtime = Path(folder).resolve() + manifest, checksum = probe.diagnostic_manifest(runtime, "converted") + environment = native.native_launch_environment(runtime, "converted", diagnostic_environment=manifest, + diagnostic_environment_sha256=checksum) + self.assertEqual("-Dmixin.debug.export=true -Dmixin.debug.export.decompile=false", environment["JAVA_TOOL_OPTIONS"]) + receipt = native.read_json(runtime / "native61-launch-environment-converted.json") + self.assertEqual("DIAGNOSTIC_NOT_QUALIFIED", receipt["status"]) + self.assertIs(False, receipt["qualificationAllowed"]) + document = json.loads(manifest.read_text()) + document["properties"]["advantage.order"] = "other" + manifest.write_text(json.dumps(document)) + with self.assertRaises(ValueError): + native.native_launch_environment(runtime, "converted", diagnostic_environment=manifest, + diagnostic_environment_sha256=native.digest(manifest)) + + def test_inherited_options_still_fail(self): + with patch.dict(os.environ, {"JAVA_TOOL_OPTIONS": "-javaagent:forbidden.jar"}): + with self.assertRaises(ValueError): + native.native_launch_environment() + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_player_equipment.py b/test-rig/migration/test_native12111_player_equipment.py new file mode 100644 index 00000000..69b70b84 --- /dev/null +++ b/test-rig/migration/test_native12111_player_equipment.py @@ -0,0 +1,127 @@ +"""Native-only player equipment placement; item loss must remain red.""" + +import copy +from pathlib import Path +import unittest + +import actors +import native12111_actors as native + + +class NativePlayerEquipmentTest(unittest.TestCase): + def setUp(self): + self.player = {"Inventory": [ + {"id": "minecraft:dirt", "Slot": 0, "count": 17}, + *({"id": "minecraft:" + item, "Slot": slot, "count": 1, "components": { + "minecraft:damage": damage, + "minecraft:enchantments": {"minecraft:unbreaking": 3}, + "minecraft:custom_data": {"Nested": {"OwnerNote": "keep-this-verbatim"}}, + }} for slot, item, damage in ((100, "iron_boots", 9), (101, "iron_leggings", 10), + (102, "iron_chestplate", 11), (103, "iron_helmet", 12), + (-106, "shield", 5)))], "Health": 20} + self.expected = native.expected_player_equipment(self.player) + + def check(self, actual): + return native.verdict(actors.compare, {"file": self.expected, "entities": {}}, + {"file": actual, "entities": {}}) + + def test_exact_five_slot_placement_and_every_item_field_are_preserved(self): + original = copy.deepcopy(self.player) + self.assertEqual([self.player["Inventory"][0]], self.expected["Inventory"]) + self.assertEqual({"feet", "legs", "chest", "head", "offhand"}, set(self.expected["equipment"])) + for item in self.player["Inventory"][1:]: + self.assertEqual({key: value for key, value in item.items() if key != "Slot"}, + self.expected["equipment"][native.PLAYER_EQUIPMENT_SLOTS[item["Slot"]]]) + self.assertEqual(original, self.player) + self.assertEqual("PASS", self.check(copy.deepcopy(self.expected))["status"]) + + def test_native_observation_includes_complete_unmodified_equipment(self): + value = copy.deepcopy(self.expected) + value["equipment"]["head"]["other:opaque"] = {"Slot": 103, "Count": 7, "id": "literal"} + before = copy.deepcopy(value) + actual = native.player_semantic(value, actors.PLAYER_KEYS, {}) + self.assertEqual(value["equipment"], actual["equipment"]) + self.assertEqual(before, value) + + def test_missing_each_equipment_stack_or_whole_map_is_rejected(self): + for slot in (*self.expected["equipment"], None): + actual = copy.deepcopy(self.expected) + if slot is None: + del actual["equipment"] + else: + del actual["equipment"][slot] + with self.subTest(slot=slot): + self.assertEqual("FAIL", self.check(actual)["status"]) + + def test_wrong_slot_count_damage_enchantment_custom_data_and_extra_data_are_rejected(self): + variants = [] + for key, value in (("count", 2), ("id", "minecraft:diamond_helmet"), ("Slot", 103)): + actual = copy.deepcopy(self.expected) + actual["equipment"]["head"][key] = value + variants.append(actual) + for component, value in (("minecraft:damage", 0), ("minecraft:enchantments", {}), + ("minecraft:custom_data", {}), ("other:new", 1)): + actual = copy.deepcopy(self.expected) + actual["equipment"]["head"]["components"][component] = value + variants.append(actual) + actual = copy.deepcopy(self.expected) + actual["equipment"]["head"], actual["equipment"]["offhand"] = ( + actual["equipment"]["offhand"], actual["equipment"]["head"]) + variants.append(actual) + actual = copy.deepcopy(self.expected) + actual["equipment"]["body"] = copy.deepcopy(actual["equipment"]["head"]) + variants.append(actual) + for index, actual in enumerate(variants): + with self.subTest(index=index): + self.assertEqual("FAIL", self.check(actual)["status"]) + self.assertEqual("FAIL", self.check(actual)["player_status"]) + + def test_no_extra_equipment_is_allowed_even_when_none_expected(self): + result = native.verdict(actors.compare, {"file": {"Inventory": []}, "entities": {}}, + {"file": {"Inventory": [], "equipment": self.expected["equipment"]}, "entities": {}}) + self.assertEqual("FAIL", result["status"]) + + def test_unopened_conversion_file_stays_historical_but_host_and_read_states_move(self): + source = {key: copy.deepcopy(self.player) for key in ("file", "adapter", "embedded")} + source["entities"] = {} + original = copy.deepcopy(source) + converted = native.expected_state(source, "actors", adapter=False) + self.assertEqual(self.player, converted["file"]) + self.assertNotIn("adapter", converted) + self.assertEqual(self.expected, converted["embedded"]) + loaded = native.expected_state(source, "actors") + for key in ("file", "adapter", "embedded"): + self.assertEqual(self.expected, loaded[key]) + self.assertEqual(original, source) + self.assertEqual(loaded, native.expected_state(loaded, "actors")) + + def test_unknown_slots_are_preserved_and_duplicate_or_ambiguous_layouts_rejected(self): + unknown = copy.deepcopy(self.player) + unknown["Inventory"].append({"id": "minecraft:apple", "Slot": 99, "count": 2}) + self.assertEqual(unknown["Inventory"][-1], native.expected_player_equipment(unknown)["Inventory"][-1]) + variants = [] + duplicate = copy.deepcopy(self.player) + duplicate["Inventory"].append(copy.deepcopy(duplicate["Inventory"][1])) + variants.append(duplicate) + mixed = copy.deepcopy(self.player) + mixed["equipment"] = {} + variants.append(mixed) + for bad in (None, True, "100"): + malformed = copy.deepcopy(self.player) + malformed["Inventory"][1]["Slot"] = bad + variants.append(malformed) + for value in variants: + with self.subTest(value=value), self.assertRaises(ValueError): + native.expected_player_equipment(value) + + def test_native61_wolf_controls_are_separately_pinned_not_historical_controls(self): + root = (Path(__file__).resolve().parents[4] / "_verification/base-12111-port-20261111" + / "native12111-wolf-platform-native-a") + for version, checksum in native.WOLF_CONTROL_HASHES.items(): + path = root / f"legacy-{version}-receipt.json" + native.native.validate_hash(path, checksum) + self.assertEqual(4671, native.native.read_json(path)["targetDataVersion"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_recipe_adapter.py b/test-rig/migration/test_native12111_recipe_adapter.py new file mode 100644 index 00000000..3cea1ad1 --- /dev/null +++ b/test-rig/migration/test_native12111_recipe_adapter.py @@ -0,0 +1,58 @@ +"""The newer engine may select native metadata without rewriting old port fixtures.""" + +import copy +import json +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import native117_recipes as shared +from native12111_recipes import Engine + + +class RecipeAdapter(unittest.TestCase): + def generated_metadata(self, native): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + + def stage(runtime, *_): + runtime.mkdir() + return "unused-no-server" + + engine = SimpleNamespace(stage=stage, server=object, version=("fixture", "fixture"), + result_name="unused-results.json", loot_directory="loot_table") + if native: + engine.pack_metadata = copy.deepcopy(Engine.pack_metadata) + else: + engine.pack_format = 48 + with patch.object(shared, "MODES", ()): + shared.progression_campaign(root, None, None, None, 25000, {}, engine=engine) + metadata = root / "progression" / shared.WORLD / "datapacks/progression-override/pack.mcmeta" + return json.loads(metadata.read_text())["pack"] + + def test_native_exact_minor_format_is_used(self): + self.assertEqual(Engine.pack_metadata, self.generated_metadata(True)) + self.assertNotIn("pack_format", Engine.pack_metadata) + + def test_existing_engines_keep_their_own_format(self): + self.assertEqual(48, self.generated_metadata(False)["pack_format"]) + + def test_native_keyed_pruning_must_match_the_exact_removed_recipe_set(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "console.log" + line = "[Server thread/ERROR] [minecraft/ServerRecipeBook]: Tried to load unrecognized recipe: ResourceKey[minecraft:recipe / basepower:sprocket_stickwood_normal] removed now." + path.write_text(line) + result = shared.audit_unlock_log(path, {"basepower:sprocket_stickwood_normal"}, set(), + errors=lambda text: text.splitlines()) + self.assertEqual(["basepower:sprocket_stickwood_normal"], result["observed_removed_ids"]) + for replacement in ("other:recipe", "basepower:sprocket_stickwood_normal] corrupted"): + path.write_text(line.replace("basepower:sprocket_stickwood_normal", replacement)) + with self.assertRaises(AssertionError): + shared.audit_unlock_log(path, {"basepower:sprocket_stickwood_normal"}, set(), + errors=lambda text: text.splitlines()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_stopped_actor_reference.py b/test-rig/migration/test_native12111_stopped_actor_reference.py new file mode 100644 index 00000000..ee1efd80 --- /dev/null +++ b/test-rig/migration/test_native12111_stopped_actor_reference.py @@ -0,0 +1,140 @@ +import copy +import json +from pathlib import Path +import tempfile +import unittest +import uuid + +import nbtlib as n +from matrix import WORLD, digest, fingerprint, write_json +import native12111_stopped_actor_reference as reference +from test_native117_entity_storage import put + + +class SourceStoppedReferenceTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.source = self.base / "source" + self.world = self.source / WORLD + (self.world / "playerdata").mkdir(parents=True) + self.player_uuid = uuid.UUID("a614e658-6270-33c3-9297-7ce80908964f") + words = [(self.player_uuid.int >> shift) & 0xffffffff for shift in (96, 64, 32, 0)] + self.words = [value - (1 << 32) if value >= (1 << 31) else value for value in words] + self.stack = n.Compound(id=n.String("minecraft:shield"), count=n.Int(1), Slot=n.Byte(-106), + components=n.Compound({"minecraft:damage": n.Int(5), "minecraft:custom_data": n.Compound( + Note=n.String("exact"), Nested=n.Compound(id=n.String("opaque"), Count=n.Int(19)))})) + self.player = n.Compound(DataVersion=n.Int(3955), UUID=n.IntArray(self.words), + Inventory=n.List[n.Compound]([copy.deepcopy(self.stack)]), EnderItems=n.List[n.Compound](), Health=n.Float(20)) + self.player_path = self.world / "playerdata" / (str(self.player_uuid) + ".dat") + n.File(self.player).save(self.player_path, gzipped=True) + n.File(n.Compound(Data=n.Compound(DataVersion=n.Int(3955), Player=copy.deepcopy(self.player)))).save(self.world / "level.dat", gzipped=True) + write_json(self.source / "actor-fixture.json", dict(playerUuid=str(self.player_uuid), minX=128, maxX=191)) + self.entities = [] + for index, (witness, identity) in enumerate(reference.WITNESSES.items()): + entity = n.Compound(id=n.String(identity), UUID=n.IntArray([0, 0, 0, index + 1]), + Pos=n.List[n.Double]([128.5 + index, 100, 8.5]), Motion=n.List[n.Double]([0, -0.12, 0]), + Rotation=n.List[n.Float]([0, 0]), ForgeData=n.Compound(AdvWorksActor=n.String(witness)), + UnknownPersistent=n.Compound(Keep=n.Byte(7))) + if identity == "minecraft:item": + entity["Item"] = copy.deepcopy(self.stack) + del entity["Item"]["Slot"] + self.entities.append(entity) + self.save_entities() + self.approval = self.base / "approval.json" + self.approve() + + def save_entities(self): + root = n.Compound(DataVersion=n.Int(3955), Position=n.IntArray([8, 0]), Entities=n.List[n.Compound](self.entities)) + put(self.world, (8, 0), value=root) + + def approve(self): + write_json(self.approval, dict(source=str(self.source.resolve()), sourceFingerprint=fingerprint(self.source))) + + def seal(self): + self.reference = reference.seal(self.source, self.approval, digest(self.approval), self.base / "new-reference") + return self.reference + + def validate(self): + return reference.validate(self.reference, digest(self.reference), self.source, digest(self.approval)) + + def test_only_stopped_disk_builds_complete_reference_and_retains_unknown_nbt(self): + before = fingerprint(self.source) + self.seal() + result = self.validate() + self.assertEqual(-0.12, result["entities"]["dropped-musket"]["Motion"][1]) + self.assertEqual({"cart", "dropped-musket", "dropped-oil", "owned-wolf", "hydroturbine"}, set(result["entities"])) + document = json.loads(self.reference.read_text()) + self.assertIs(False, document["targetObservationsUsed"]) + raw = reference.storage._nbt((self.reference.parent / "raw/witnesses/dropped-musket.nbt").read_bytes()) + self.assertEqual(n.Byte(7), raw["UnknownPersistent"]["Keep"]) + self.assertEqual(19, result["file"]["Inventory"][0]["components"]["minecraft:custom_data"]["Nested"]["Count"]) + self.assertEqual(before, fingerprint(self.source)) + self.assertFalse((self.source / "actors-runtime-restart.json").exists()) + + def test_live_serializer_snapshot_is_not_a_golden_input(self): + write_json(self.source / "actors-runtime-restart.json", dict(entities=[dict(Motion=[0, 999, 0])])) + self.approve() + self.seal() + self.assertEqual(-0.12, self.validate()["entities"]["dropped-musket"]["Motion"][1]) + + def test_changed_golden_is_rejected_even_after_its_sha_is_refreshed(self): + self.seal() + document = json.loads(self.reference.read_text()) + document["expected"]["entities"]["dropped-musket"]["Motion"][1] = 123 + write_json(self.reference, document) + with self.assertRaisesRegex(AssertionError, "golden does not derive"): + self.validate() + + def test_changed_source_and_wrong_approval_fail_closed(self): + self.seal() + self.entities[0]["UnknownPersistent"]["Keep"] = n.Byte(8) + self.save_entities() + with self.assertRaisesRegex(AssertionError, "Unbound or changed"): + self.validate() + with self.assertRaises(AssertionError): + reference.seal(self.source, self.approval, "0" * 64, self.base / "other") + + def test_missing_changed_extra_raw_assets_or_target_evidence_are_rejected(self): + self.seal() + artifact = self.reference.parent / "raw/player.dat" + before = artifact.read_bytes() + artifact.write_bytes(before + b"tamper") + with self.assertRaises(AssertionError): + self.validate() + artifact.write_bytes(before) + extra = self.reference.parent / "target-observation.json" + extra.write_text("{}") + with self.assertRaisesRegex(AssertionError, "target evidence is forbidden"): + self.validate() + + def test_target_used_flag_wrong_source_version_or_wrong_player_identity_reject(self): + self.seal() + document = json.loads(self.reference.read_text()) + document["targetObservationsUsed"] = True + write_json(self.reference, document) + with self.assertRaises(AssertionError): + self.validate() + self.player["UUID"] = n.IntArray([0, 0, 0, 99]) + n.File(self.player).save(self.player_path, gzipped=True) + with self.assertRaisesRegex(AssertionError, "player identity"): + reference.disk_state(self.source) + + def test_duplicate_uuid_missing_witness_or_wrong_chunk_version_reject(self): + self.entities[1]["UUID"] = self.entities[0]["UUID"] + self.save_entities() + with self.assertRaisesRegex(AssertionError, "Duplicate source entity UUID"): + reference.disk_state(self.source) + self.entities.pop(1) + self.save_entities() + with self.assertRaisesRegex(AssertionError, "exact five"): + reference.disk_state(self.source) + root = n.Compound(DataVersion=n.Int(4671), Position=n.IntArray([8, 0]), Entities=n.List[n.Compound](self.entities)) + put(self.world, (8, 0), value=root) + with self.assertRaises(ValueError): + reference.disk_state(self.source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_upgrade_components.py b/test-rig/migration/test_native12111_upgrade_components.py new file mode 100644 index 00000000..f7cfc284 --- /dev/null +++ b/test-rig/migration/test_native12111_upgrade_components.py @@ -0,0 +1,163 @@ +import copy +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import native12111_upgrade as upgrade + + +class ExpectedComponentsTest(unittest.TestCase): + def stack(self): + return {"id": "basesteam:musket", "count": 1, "Slot": 3, "unknown": [7, {"marker": "keep"}], + "components": {"minecraft:damage": 5, + "minecraft:enchantments": {"levels": {"basesteam:powderless": 1}}, + "minecraft:stored_enchantments": {"levels": {"minecraft:unbreaking": 255}}, + "minecraft:custom_data": {"loaded": 1, "unknown": {"levels": {"x": "keep"}}}, + "other:opaque": {"id": "other:item", "count": 1, "components": { + "minecraft:enchantments": {"levels": {"other:opaque": 2}}}}}} + + def adapt(self, value, version="1.20.6"): + return upgrade.expected_components(value, source_version=version) + + def test_both_modern_versions_translate_only_the_two_enchantment_wrappers(self): + for version in ("1.20.6", "1.21.1"): + with self.subTest(version=version): + source = {"Items": [self.stack()], "components": {}, "unknown": {"marker": [1, 2]}} + original = copy.deepcopy(source) + expected = copy.deepcopy(source) + expected["Items"][0]["components"]["minecraft:enchantments"] = {"basesteam:powderless": 1} + expected["Items"][0]["components"]["minecraft:stored_enchantments"] = {"minecraft:unbreaking": 255} + actual = self.adapt(source, version) + self.assertEqual(expected, actual) + self.assertEqual(original, source) + actual["Items"][0]["components"]["other:opaque"]["count"] = 99 + self.assertEqual(original, source) + + def test_empty_enchantments_stay_present_and_absent_components_stay_absent(self): + value = self.stack() + value["components"] = {"minecraft:enchantments": {"levels": {}}, + "minecraft:stored_enchantments": {"levels": {}}} + expected = copy.deepcopy(value) + expected["components"] = {"minecraft:enchantments": {}, "minecraft:stored_enchantments": {}} + self.assertEqual(expected, self.adapt(value)) + del value["components"] + self.assertEqual(value, self.adapt(value)) + + def test_legacy_1201_translation_is_retained(self): + source = {"id": "basesteam:musket", "Count": 1, "Slot": 3, + "tag": {"Damage": 5, "Enchantments": [{"id": "basesteam:powderless", "lvl": 1}], + "StoredEnchantments": [{"id": "minecraft:unbreaking", "lvl": 2}], "unknown": [7]}} + original = copy.deepcopy(source) + self.assertEqual(upgrade.components(source), self.adapt(source, "1.20.1")) + self.assertEqual(original, source) + + def test_source_version_is_required_and_native_observations_are_not_a_source_format(self): + with self.assertRaises(TypeError): + upgrade.expected_components(self.stack()) + for version in (None, "1.20.5", "1.21", "1.21.11", 1.21, True, {}): + with self.subTest(version=version), self.assertRaisesRegex(AssertionError, "source.*version"): + self.adapt(self.stack(), version) + + def test_modern_sources_reject_legacy_or_mixed_count_representation(self): + for version in ("1.20.6", "1.21.1"): + for mixed in (False, True): + value = self.stack() + value["Count"] = 1 + if not mixed: + del value["count"] + with self.subTest(version=version, mixed=mixed), self.assertRaisesRegex(AssertionError, "count"): + self.adapt(value, version) + + def test_modern_stack_and_component_types_are_not_coerced(self): + for field, invalid in (("count", True), ("count", "1"), ("count", 1.0), ("count", 0), + ("id", 1), ("id", ""), ("components", []), ("components", None)): + value = self.stack() + value[field] = invalid + with self.subTest(field=field, invalid=invalid), self.assertRaises(AssertionError): + self.adapt(value) + + def test_wrapper_fields_are_exact_and_tooltip_changes_require_a_separate_proven_adapter(self): + for wrapper in ({}, {"levels": {}, "unknown": 7}, {"levels": {}, "show_in_tooltip": False}, + {"basesteam:powderless": 1}, [], None, True): + for component in ("minecraft:enchantments", "minecraft:stored_enchantments"): + value = self.stack() + value["components"][component] = wrapper + original = copy.deepcopy(value) + with self.subTest(component=component, wrapper=wrapper), self.assertRaisesRegex(AssertionError, "wrapper"): + self.adapt(value) + self.assertEqual(original, value) + + def test_levels_require_a_map_of_exact_identifiers_and_native_integer_range(self): + for levels in ([], None, True, 1, {7: 1}, {"": 1}, {"bad id": 1}, {"other:bad name": 1}, + {"minecraft:unbreaking": True}, {"minecraft:unbreaking": "1"}, + {"minecraft:unbreaking": 1.0}, {"minecraft:unbreaking": 0}, + {"minecraft:unbreaking": -1}, {"minecraft:unbreaking": 256}): + value = self.stack() + value["components"]["minecraft:enchantments"] = {"levels": levels} + with self.subTest(levels=levels), self.assertRaises(AssertionError): + self.adapt(value) + + def receipt_gate(self, declared, data_version, *, historical=False): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) + args = SimpleNamespace(source=source, legacy_reference=source if historical else None, + receipt_sha256="sealed", profile=source, seed=source, inventory=source, + inventory_sha256="sealed") + receipt = {"status": "PASS", "sourceUnchanged": True, "phases": {"restarted": { + "status": "PASS", "snapshotSha256": "sealed", "serialized": {"nativeDataVersion": data_version}}}} + if declared != "absent": + receipt["minecraft"] = declared + with patch.object(upgrade, "validate_hash"), patch.object(upgrade, "read_json", side_effect=[receipt, []]), \ + patch.object(upgrade, "read_family_target", side_effect=RuntimeError("source-version-accepted")): + upgrade.run(args) + + def test_sealed_receipt_gate_accepts_exact_modern_versions_and_the_proven_legacy_fallback(self): + for version, data_version in (("1.20.1", 3465), ("1.20.6", 3839), ("1.21.1", 3955)): + with self.subTest(version=version), self.assertRaisesRegex(RuntimeError, "source-version-accepted"): + self.receipt_gate(version, data_version) + with self.assertRaisesRegex(RuntimeError, "source-version-accepted"): + self.receipt_gate("absent", 3465, historical=True) + + def test_sealed_receipt_gate_rejects_mismatch_missing_version_and_untyped_data_version(self): + for version, data_version, historical in (("1.20.6", 3955, False), ("1.21.1", 3839, False), + ("absent", 3465, False), ("absent", 3839, True), + (None, 3465, True), ("1.20.6", "3839", False), + ("1.20.6", 3839.0, False), ("1.20.6", True, False)): + with self.subTest(version=version, data_version=data_version), \ + self.assertRaisesRegex(AssertionError, "source component version"): + self.receipt_gate(version, data_version, historical=historical) + + def test_comparison_adapts_only_expected_values_and_keeps_negative_controls(self): + witness = {"name": "chest", "x": 1, "y": 5, "z": 1, "actualName": "minecraft:chest", + "properties": {}, "nbtBase64": "fixture"} + source = {"Items": [self.stack()], "components": {}} + native = self.adapt(source) + for version in ("1.20.6", "1.21.1"): + for change in (None, "level", "count", "extra", "missing", "wrapper", "unknown"): + observed = copy.deepcopy(native) + stack = observed["Items"][0] + enchantments = stack["components"]["minecraft:enchantments"] + if change == "level": + enchantments["basesteam:powderless"] = 2 + elif change == "count": + stack["count"] = 2 + elif change == "extra": + enchantments["other:added"] = 1 + elif change == "missing": + del enchantments["basesteam:powderless"] + elif change == "wrapper": + stack["components"]["minecraft:enchantments"] = {"levels": enchantments} + elif change == "unknown": + stack["components"]["other:added"] = 7 + original = copy.deepcopy((source, observed)) + with self.subTest(version=version, change=change), patch.object( + upgrade, "parse_nbt", side_effect=[source, observed]): + result = upgrade.comparison([witness], [witness], modern=True, source_version=version) + self.assertEqual("PASS" if change is None else "FAIL", result["status"]) + self.assertEqual(original, (source, observed)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native12111_upgrade_readiness.py b/test-rig/migration/test_native12111_upgrade_readiness.py new file mode 100644 index 00000000..c07d710f --- /dev/null +++ b/test-rig/migration/test_native12111_upgrade_readiness.py @@ -0,0 +1,28 @@ +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +import native12111_upgrade as upgrade + + +class UpgradeReadinessTest(unittest.TestCase): + def test_every_distinct_machine_chunk_is_loaded_before_observation(self): + server, runtime = Mock(), Path("disposable-upgrade") + positions = {"a": (16, 5, -1), "same": (31, 8, -8), "b": (-1, 5, 32)} + with patch.object(upgrade, "await_ready", return_value={"status": "PASS"}) as ready: + result = upgrade.load_operating_chunks(server, runtime, positions) + self.assertEqual(["forceload add -16 32", "forceload add 16 -16"], + [call.args[0] for call in server.send.call_args_list]) + self.assertEqual([(-1, 2, -1, 2), (1, -1, 1, -1)], + [call.args[2] for call in ready.call_args_list]) + self.assertTrue(all(call.kwargs["minecraft"] == "1.21.11" for call in ready.call_args_list)) + self.assertEqual(2, len(result)) + + def test_failed_readiness_is_not_accepted_as_an_operating_chunk(self): + with patch.object(upgrade, "await_ready", side_effect=AssertionError("not ticking")): + with self.assertRaisesRegex(AssertionError, "not ticking"): + upgrade.load_operating_chunks(Mock(), Path("disposable-upgrade"), {"a": (16, 5, -1)}) + + def test_missing_machine_coordinates_fail_closed(self): + with self.assertRaisesRegex(AssertionError, "explicit machine coordinates"): + upgrade.load_operating_chunks(Mock(), Path("disposable-upgrade"), {}) diff --git a/test-rig/migration/test_native12111_worldgen.py b/test-rig/migration/test_native12111_worldgen.py new file mode 100644 index 00000000..551ca953 --- /dev/null +++ b/test-rig/migration/test_native12111_worldgen.py @@ -0,0 +1,268 @@ +import copy +import json +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch + +import native12111_worldgen as worldgen + + +class WorldgenAdapterTest(unittest.TestCase): + def setUp(self): + self.spec = worldgen.campaign_spec("advantage") + self.mods = {key: ("fixture.121111", Path(key + ".jar"), "A" * 64) + for key in self.spec["requiredMods"] | {"basemetals"}} + + def sample(self, spec=None, mods=None): + spec, mods = spec or self.spec, mods or self.mods + counts = dict.fromkeys(worldgen.BLOCKS, 0) + counts.update(spec["worldgenExpectations"]["minimumCounts"]) + return dict(minecraft=worldgen.native.MINECRAFT, forge=worldgen.native.FORGE, + station="worldgen", kind="sample", mods={key: value[0] for key, value in mods.items()}, + radius=spec["worldgenSampleRadius"], chunksScanned=12, + centers={"spawn": "0,0", "DESERT": "16,-16", "OCEAN": "-20,20"}, + counts=counts, biomeViolations=dict.fromkeys(worldgen.OILS, 0), + biomeBoundaryBlocks=dict.fromkeys(worldgen.OILS, 0), + chunkLoading=dict(protocol="native_FULL_off_thread_future_poll_v1", maximumInFlight=4, + peakInFlight=2, requests=30, releasedTickets=30, activeTickets=0, + dispatcherClosed=True, executorShutdown=True)) + + def test_all_existing_campaign_oracles_are_retained(self): + for name in worldgen.CAMPAIGNS: + spec = worldgen.campaign_spec(name) + original = worldgen.native.read_json(spec["template"]) + for key in ("firstProfileAssertions", "restartProfileAssertions", "worldgenExpectations", + "profileMutations", "firstFileAssertions", "restartFileAssertions", + "stableWorldgenCountExclusions"): + self.assertEqual(original.get(key, []), spec[key], (name, key)) + legacy = worldgen.campaign_spec("legacy-import") + self.assertEqual(2, len(legacy["configFixtures"])) + self.assertTrue(all(source.is_file() and "PA12111" in source.parts for source, _ in legacy["configFixtures"])) + + def test_mod_selection_keeps_all_frozen_family_jars_without_adding_other_providers(self): + mods = {**self.mods, "mineralogy": ("extra.121111", Path("extra.jar"), "B" * 64)} + self.assertEqual(self.mods, worldgen.select_mods(mods, self.spec)) + with self.assertRaisesRegex(ValueError, "lacks worldgen inputs"): + worldgen.select_mods({key: value for key, value in mods.items() if key != "orespawn"}, self.spec) + + def test_staging_explicitly_removes_flat_properties_before_first_boot(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + properties = runtime / "server.properties" + properties.write_text("level-type=minecraft:flat\ngenerator-settings=flat-data\n" + "generate-structures=false\nserver-ip=127.0.0.1\n", encoding="utf-8") + worldgen.configure_normal_world(runtime, self.spec, 26480) + lines = dict(line.split("=", 1) for line in properties.read_text().splitlines()) + self.assertEqual("minecraft:normal", lines["level-type"]) + self.assertEqual("", lines["generator-settings"]) + self.assertEqual("true", lines["generate-structures"]) + self.assertEqual("-1", lines["pause-when-empty-seconds"]) + self.assertEqual("127.0.0.1", lines["server-ip"]) + self.assertEqual(worldgen.WORLD, lines["level-name"]) + with self.assertRaisesRegex(AssertionError, "Unsafe"): + worldgen.configure_normal_world(runtime, {**self.spec, "levelSeed": "bad\nserver-ip=0.0.0.0"}, 26480) + + def test_valid_native_sample_is_read_without_mutation(self): + sample = self.sample() + before = copy.deepcopy(sample) + worldgen.validate_sample(sample, self.spec, self.mods) + self.assertEqual(before, sample) + + def test_sampler_and_command_failures_abort_waits_without_waiting_for_the_full_deadline(self): + for line in ("Worldgen sample failed", "Advantage Works command failed", + "Server empty for 60 seconds, pausing"): + self.assertTrue(worldgen.WorldgenServer.unexpected_line(line)) + self.assertFalse(worldgen.WorldgenServer.unexpected_line("Worldgen sample wrote 12 chunks")) + + def test_missing_wrong_typed_negative_and_missing_deposit_counts_fail_closed(self): + for change in (lambda row: row.pop("counts"), lambda row: row["counts"].pop("mineralogy:crude_oil"), + lambda row: row["counts"].update({"basepower:crude_oil": 0}), + lambda row: row["counts"].update({"basepower:crude_oil": True}), + lambda row: row["counts"].update({"basepower:crude_oil": -1})): + row = self.sample() + change(row) + with self.assertRaises(AssertionError): + worldgen.validate_sample(row, self.spec, self.mods) + + def test_wrong_native_receipt_or_unfound_desert_cannot_qualify(self): + for key, value in (("minecraft", "1.21.1"), ("forge", "52.1.0"), ("kind", "sample-failed"), + ("radius", 1), ("chunksScanned", 0)): + sample = self.sample() + sample[key] = value + with self.assertRaises(AssertionError): + worldgen.validate_sample(sample, self.spec, self.mods) + sample = self.sample() + sample["centers"]["DESERT"] = "NOT_FOUND" + with self.assertRaisesRegex(AssertionError, "biome was not found"): + worldgen.validate_sample(sample, self.spec, self.mods) + sample = self.sample() + sample["mods"]["basepower"] = "another-version" + with self.assertRaisesRegex(AssertionError, "mod versions"): + worldgen.validate_sample(sample, self.spec, self.mods) + + def test_oil_biome_violation_is_not_silenced_as_a_boundary_block(self): + sample = self.sample() + sample["biomeBoundaryBlocks"]["basepower:crude_oil"] = 3 + worldgen.validate_sample(sample, self.spec, self.mods) + sample["biomeViolations"]["basepower:crude_oil"] = 1 + with self.assertRaisesRegex(AssertionError, "outside"): + worldgen.validate_sample(sample, self.spec, self.mods) + + def test_incomplete_ticket_custody_and_unbounded_dispatch_fail(self): + for key, value in (("protocol", "blocking"), ("maximumInFlight", 5), ("peakInFlight", 5), + ("requests", 0), ("releasedTickets", 29), ("activeTickets", 1), + ("dispatcherClosed", False), ("executorShutdown", False)): + sample = self.sample() + sample["chunkLoading"][key] = value + with self.assertRaisesRegex(AssertionError, "bounded tickets"): + worldgen.validate_sample(sample, self.spec, self.mods) + + def test_restart_preserves_coverage_and_counts_with_only_historical_legacy_oil_exception(self): + first, restarted = self.sample(), self.sample() + worldgen.validate_restart(first, restarted, self.spec) + restarted["counts"]["baseelectric:li_ore"] += 1 + with self.assertRaisesRegex(AssertionError, "measurements changed"): + worldgen.validate_restart(first, restarted, self.spec) + restarted = self.sample() + restarted["centers"]["DESERT"] = "100,100" + with self.assertRaisesRegex(AssertionError, "coverage moved"): + worldgen.validate_restart(first, restarted, self.spec) + legacy = worldgen.campaign_spec("legacy-import") + restarted = self.sample() + restarted["counts"]["basepower:crude_oil"] += 1 + worldgen.validate_restart(first, restarted, legacy) + with self.assertRaisesRegex(AssertionError, "Unreviewed"): + worldgen.validate_restart(first, first, {**self.spec, "stableWorldgenCountExclusions": ["baseelectric:li_ore"]}) + + def test_native_stopped_save_must_prove_normal_terrain(self): + data = dict(DataVersion=4671, LevelName=worldgen.WORLD, WorldGenSettings={ + "seed": 1234, "dimensions": {"minecraft:overworld": {"generator": { + "type": "minecraft:noise", "settings": "minecraft:overworld"}}}}) + before = copy.deepcopy(data) + self.assertEqual(data["WorldGenSettings"], worldgen.normal_world(data)) + self.assertEqual(before, data) + data["WorldGenSettings"]["dimensions"]["minecraft:overworld"]["generator"]["type"] = "minecraft:flat" + with self.assertRaisesRegex(AssertionError, "actual normal terrain"): + worldgen.normal_world(data) + + def test_profile_bool_types_presence_and_schema_remain_strict(self): + assertion = dict(id="enabled", section="ores", entry="baseelectric:ore/sulfur", field="enabled", + present=True, expectedBoolean=True) + document = dict(schema_version=7, ores={"baseelectric:ore/sulfur": {"enabled": True}}) + self.assertEqual("PASS", worldgen.validate_profile(document, [assertion])[0]["status"]) + for value in (1, "true", False, None): + document["ores"]["baseelectric:ore/sulfur"]["enabled"] = value + with self.assertRaises(AssertionError): + worldgen.validate_profile(document, [assertion]) + document["schema_version"] = 6 + with self.assertRaisesRegex(AssertionError, "schema 7"): + worldgen.validate_profile(document, []) + + def test_explicit_null_entry_does_not_count_as_an_absent_duplicate_rule(self): + assertion = dict(id="no-duplicate", section="ores", entry="modern", present=False) + document = dict(schema_version=7, ores={}) + worldgen.validate_profile(document, [assertion]) + document["ores"]["modern"] = None + with self.assertRaisesRegex(AssertionError, "presence"): + worldgen.validate_profile(document, [assertion]) + + def test_provider_hierarchy_matches_native_electric_defaults(self): + for providers, sulfur, lithium in ((set(), True, True), ({"mineralogy"}, False, True), + ({"baseminerals"}, False, False), ({"mineralogy", "baseminerals"}, False, False)): + document = dict(fluid_deposits={ + "basepower:fluid_deposit/desert_crude_oil": {"source_provider": "basepower"}, + "mineralogy:fluid_deposit/crude_oil": {"source_provider": "mineralogy"}}, ores={ + "baseelectric:ore/sulfur": {"source_provider": "baseelectric", "enabled": sulfur}, + "baseelectric:ore/lithium": {"source_provider": "baseelectric", "enabled": lithium}}) + worldgen.validate_initial_hierarchy(document, providers) + document["ores"]["baseelectric:ore/sulfur"]["enabled"] = not sulfur + with self.assertRaisesRegex(AssertionError, "hierarchy"): + worldgen.validate_initial_hierarchy(document, providers) + + def test_user_profile_mutations_do_not_modify_other_fields_or_original_document(self): + before = dict(ores={"ore": {"enabled": True, "dimensions": {"kept": 7}}}, unrelated={"keep": [1, 2]}) + expected = copy.deepcopy(before) + expected["ores"]["ore"]["enabled"] = False + changed = worldgen.mutated_profile(before, [dict(section="ores", entry="ore", field="enabled", booleanValue=False)]) + self.assertEqual(expected, changed) + self.assertTrue(before["ores"]["ore"]["enabled"]) + with self.assertRaises(AssertionError): + worldgen.mutated_profile(before, [dict(section="ores", entry="ore", field="absent", booleanValue=False)]) + + def test_import_file_witnesses_reject_missing_wrong_and_escaping_paths(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + row = dict(id="import", path="report.json", contains="os1_config_source", minimumOccurrences=2) + with self.assertRaisesRegex(AssertionError, "Missing"): + worldgen.check_files(runtime, [row]) + (runtime / "report.json").write_text("os1_config_source\nos1_config_source\n") + worldgen.check_files(runtime, [row]) + with self.assertRaisesRegex(AssertionError, "Incomplete"): + worldgen.check_files(runtime, [{**row, "minimumOccurrences": 3}]) + with self.assertRaisesRegex(AssertionError, "escaped"): + worldgen.check_files(runtime, [{**row, "path": "../outside.json"}]) + + def test_mineralogy_campaign_refuses_unapproved_public_artifact(self): + with self.assertRaisesRegex(AssertionError, "separately pinned"): + worldgen.mineralogy_input({}, None) + with self.assertRaisesRegex(AssertionError, "approved public artifact"): + worldgen.mineralogy_input({"mineralogy": ("6.1.4.121111", Path("mineralogy.jar"), "0" * 64)}, None) + + def test_mineralogy_freeze_keeps_every_family_pin_and_separates_profile_and_jar_hashes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + jar = root / "Mineralogy-6.1.4.121111.jar" + jar.write_bytes(b"public jar fixture") + sha = worldgen.digest(jar) + profile = root / "family.json" + family = [{"id": key, "version": value[0], "path": str(value[1]), "sha256": value[2]} + for key, value in self.mods.items()] + profile.write_text(json.dumps(dict(mods=family))) + before = profile.read_bytes() + runtime = root / "new-evidence" + runtime.mkdir() + mods = {**self.mods, "mineralogy": (worldgen.MINERALOGY["version"], jar, sha)} + with patch.dict(worldgen.MINERALOGY, sha256=sha): + frozen = worldgen.freeze_mineralogy_profile(runtime, profile, worldgen.digest(profile), mods) + document = json.loads(Path(frozen["path"]).read_text()) + self.assertEqual(family, [row for row in document["mods"] if row["id"] != "mineralogy"]) + self.assertEqual(before, profile.read_bytes()) + self.assertEqual(sha, frozen["jar"]["sha256"]) + self.assertEqual(worldgen.digest(Path(frozen["path"])), frozen["sha256"]) + self.assertNotEqual(frozen["sha256"], frozen["jar"]["sha256"]) + self.assertEqual(worldgen.MINERALOGY["sourceRef"], document["mineralogySourceRef"]) + self.assertEqual(jar.read_bytes(), Path(frozen["jar"]["path"]).read_bytes()) + + def test_preflight_does_not_create_runtime_or_launch_any_server(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in ("profile.json", "inventory.json", "java"): + (root / name).write_text("fixture") + mods = {} + for key, (version, _, sha) in self.mods.items(): + jar = root / (key + ".jar") + jar.write_bytes(b"fixture") + mods[key] = version, jar, sha + seed = root / "seed" + seed.mkdir() + args = SimpleNamespace(port=26480, sample_timeout=1800, profile=root / "profile.json", profile_sha256="A" * 64, + inventory=root / "inventory.json", inventory_sha256="B" * 64, seed=seed, + java=root / "java", output=root / "new-output", campaign="advantage", + preflight_only=True, mineralogy=None) + with (patch.object(worldgen.native, "validate_hash"), + patch.object(worldgen.native, "read_family_target", return_value=(seed, {}, mods)), + patch.object(worldgen.native, "java21", return_value=(args.java, "Java 21")), + patch.object(worldgen, "output_path", return_value=args.output), + patch.object(worldgen.native, "stage") as stage, patch.object(worldgen.native, "Server") as server): + result = worldgen.run(args) + self.assertEqual("READY_NOT_RUN", result["status"]) + self.assertEqual(sorted(mods), result["selectedMods"]) + stage.assert_not_called() + server.assert_not_called() + self.assertFalse(args.output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/order-probe/README.md b/test-rig/order-probe/README.md new file mode 100644 index 00000000..689fca97 --- /dev/null +++ b/test-rig/order-probe/README.md @@ -0,0 +1,27 @@ +# Local Native61 Order Probe + +This is a local, instrumented seventh-mod test. It is not a normal six-jar production qualification. No production jar is rewritten. Existing `native12111.py` / `native117_code.py` launch policy is unchanged; every export launch retains `DIAGNOSTIC_NOT_QUALIFIED` and `qualificationAllowed=false`. + +## Build And Run + +Use Java21 and the migration venv. `build.py --profile --seed --java --output ` runs independent javac using the existing Eclipse dependency classpath, not Gradle. It emits two separately pinned `AdvantageOrderProbe-1.0.0.121111.jar` variants and a build receipt. Never reuse an existing output directory. + +`migration/native12111_order_probe.py` accepts the same sealed profile/seed/inventory/Java inputs, plus the sidecar build receipt/SHA, actual original110 source, independent legacy reference/receipt SHA, new output, and a starting port in 26520..26528. It runs conversion, cold restart and resumed operation in each observed order. The six production records are retained verbatim in each derived seven-mod profile. + +## Guards And Proof + +The config-only plugin changes precisely `mixinPriority` for the two existing child configs, before preparation. It pins the six jars, both configs, all thirteen declared production mixin classes, and native Mixin0.8.7 implementation classes. It rejects duplicate resources/declarations, prepared children, explicit class priorities, unexpected defaults, and unrelated priority changes. Its own config declares no target mixins. The mod requests WorldGenRegion with class initialization disabled solely to make its real woven bytecode available in fully generated old worlds. + +The audit checks actual generated handler origins, priorities and full instruction fingerprints, observed HEAD/RETURN order, both native returns, cooperative noncancellation, inventory staging after both HEADs, and control-flow dominance including native exception edges. Both actual orders must be reverse permutations at every audited site and stable across three boots. Requests/labels alone cannot pass. + +Only two expected-body transformations are permitted: the worldgen handlers' initial `ALOAD 0; CHECKCAST WorldGenRegion` receiver casts are elided before expected hashing, exactly as pinned Mixin0.8.7 `MixinTargetContext.transformTypeNode` specifies. Observed bodies are never filtered. The rest of each method participates in its hash. + +Focused controls: `migration/test_native12111_order_probe.py`; existing launch-policy tests; `test/run_woven_controls.py` (eleven rejected derivative-class mutations); `test/run_guard_controls.py` (ten rejected synthetic-jar guard mutations). These helpers create only new synthetic artifacts, never modify child target jars. `test/retain_final_evidence.py` retains nine per-handler hashes and three raw javap views for each completed boot. + +## C2 Receipt + +Final actual result: `test-rig/build/native12111-final-c2-order-final/native12111-order-probe-results.json`, PASS, scope `INSTRUMENTED_ORDER_TEST_ONLY`, normal qualification forbidden. All source/reference/seed/input/six-production-jar custody flags are true. + +Exact final sidecar build: `test-rig/build/native12111-order-probe-build-c2-d/build-receipt.json`. Six-boot raw exports and plugin pre/post proofs remain in the runtime `order-evidence` directories; readable disassembly and body hashes are in `test-rig/build/native12111-order-probe-final-evidence-c2`. + +Earlier attempts a/b/c are preserved FAILs (missing unloaded target, strict receiver-cast comparison, then explicit review of native exception edges). They are not promoted to passes. See the verification handoff for actor fixture gaps and the independent normal six-jar C2 results. diff --git a/test-rig/order-probe/build.py b/test-rig/order-probe/build.py new file mode 100644 index 00000000..266fa6e3 --- /dev/null +++ b/test-rig/order-probe/build.py @@ -0,0 +1,146 @@ +"""Independent javac build of a pinned local-only order sidecar; no Gradle or target rewrites.""" + +import argparse +import copy +import json +import os +from pathlib import Path +import subprocess +import sys +import xml.etree.ElementTree as ET +from zipfile import ZipFile, ZipInfo, ZIP_DEFLATED + +ROOT = Path(__file__).resolve().parent +RIG = ROOT.parent +sys.path.insert(0, str(RIG / "migration")) +from matrix import digest, fingerprint, write_json +import native12111 as native + +MODS = {"basepower", "basemetals", "basesteam", "baseelectric", "orespawn", "advantageworkstest"} +CLASSES = {"basepower": ["BlockStateDataMixin", "LevelStorageSourceMixin", "PlayerDataStorageMixin", + "SimpleRegionStorageMixin", "WorldGenRegionMixin"], + "basemetals": ["AnvilBlockMixin", "AnvilMenuMixin", "BlockStateDataMixin", "LevelCapabilityDataMixin", + "LevelStorageSourceMixin", "OreSpawnEarlyTagCompatibilityMixin", "SimpleRegionStorageMixin", "WorldGenRegionMixin"]} +TARGETS = ("net/minecraft/world/level/chunk/storage/SimpleRegionStorage", + "net/minecraft/world/level/storage/LevelStorageSource", "net/minecraft/server/level/WorldGenRegion") +VERSION = "1.0.0.121111" +JAR = f"AdvantageOrderProbe-{VERSION}.jar" + + +def unique_entry(archive, name): + if archive.namelist().count(name) != 1: + raise AssertionError("Missing/duplicate pinned resource: " + name) + return archive.read(name) + + +def sha(payload): + import hashlib + return hashlib.sha256(payload).hexdigest().upper() + + +def base_pins(profile, seed): + document = native.read_json(profile) + entries = document["mods"] + if len(entries) != 6 or {entry["id"] for entry in entries} != MODS: + raise AssertionError("Order probe requires the exact six-jar production profile") + pins = dict(format=1, mixinVersion="0.8.7", profileSha256=digest(profile), mods={}, configs={}) + for entry in entries: + path = (RIG / entry["path"]).resolve(strict=True) + native.validate_hash(path, entry["sha256"]) + pins["mods"][entry["id"]] = dict(file=path.name, sha256=digest(path)) + if entry["id"] not in CLASSES: + continue + name = entry["id"] + ".mixins.json" + with ZipFile(path) as archive: + raw = unique_entry(archive, name) + config = json.loads(raw) + if (config["required"] is not True or config["compatibilityLevel"] != "JAVA_21" + or config["injectors"] != {"defaultRequire": 1} or config["mixins"] != CLASSES[entry["id"]] + or any(key in config for key in ("plugin", "priority", "mixinPriority", "client", "server"))): + raise AssertionError("Production Mixin config contract changed: " + name) + classes = {config["package"] + "." + short: sha(unique_entry(archive, + (config["package"] + "." + short).replace(".", "/") + ".class")) for short in config["mixins"]} + pins["configs"][name] = dict(mod=entry["id"], sha256=sha(raw), package=config["package"], + mixins=config["mixins"], classes=classes) + mixin = seed / "libraries/org/spongepowered/mixin/0.8.7/mixin-0.8.7.jar" + pins["mixinJarSha256"] = digest(mixin) + with ZipFile(mixin) as archive: + pins["mixinClasses"] = {name: sha(unique_entry(archive, name.replace(".", "/") + ".class")) + for name in ("org.spongepowered.asm.mixin.MixinEnvironment", + "org.spongepowered.asm.mixin.transformer.Config", + "org.spongepowered.asm.mixin.transformer.MixinConfig", + "org.spongepowered.asm.mixin.transformer.MixinInfo", + "org.spongepowered.asm.mixin.transformer.MixinProcessor")} + return pins + + +def archive(path, files): + with ZipFile(path, "x", compression=ZIP_DEFLATED) as output: + for name, data in sorted(files.items()): + item = ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + item.compress_type = ZIP_DEFLATED + output.writestr(item, data) + + +def build(profile, seed, java, output): + java, _ = native.java21(java) + pins = base_pins(profile, seed) + classpath_file = RIG.parent / ".classpath" + classpath = [Path(row.attrib["path"]) for row in ET.parse(classpath_file).getroot().findall("classpathentry") + if row.attrib.get("kind") == "lib"] + for path in classpath: + if not path.is_file(): + raise FileNotFoundError("Pinned Eclipse build dependency is absent: " + str(path)) + output.mkdir(parents=True, exist_ok=False) + classes = output / "classes" + classes.mkdir() + sources = fingerprint(ROOT) + command = [str(java.with_name("javac.exe" if os.name == "nt" else "javac")), "-proc:none", "--release", "21", + "-cp", os.pathsep.join(map(str, classpath)), "-d", str(classes), *map(str, sorted((ROOT / "src").rglob("*.java")))] + completed = subprocess.run(command, env=native.native_launch_environment(), capture_output=True, text=True) + (output / "javac.log").write_text(completed.stdout + completed.stderr, encoding="utf-8") + if completed.returncode: + raise RuntimeError("Sidecar javac failed; retained " + str(output / "javac.log")) + native_classes = output / "native-classes" + engine = RIG / ".gradle/mavenizer/repo/net/minecraftforge/forge/1.21.11-61.1.0/forge-1.21.11-61.1.0.jar" + with ZipFile(engine) as original: + for name in TARGETS: + path = native_classes / (name + ".class") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(unique_entry(original, name + ".class")) + variants = {} + for mode in ("power-low", "metals-low"): + directory = output / mode + directory.mkdir() + selected = copy.deepcopy(pins) + selected.update(mode=mode, priorities={"basepower.mixins.json": 900 if mode == "power-low" else 1100, + "basemetals.mixins.json": 1100 if mode == "power-low" else 900}) + raw = (json.dumps(selected, indent=2, sort_keys=True) + "\n").encode() + plan = directory / "order-probe-pins.json" + plan.write_bytes(raw) + files = {str(path.relative_to(classes)).replace("\\", "/"): path.read_bytes() for path in classes.rglob("*.class")} + files.update({str(path.relative_to(ROOT / "resources")).replace("\\", "/"): path.read_bytes() + for path in (ROOT / "resources").rglob("*") if path.is_file()}) + files["order-probe-pins.json"] = raw + files["META-INF/MANIFEST.MF"] = ("Manifest-Version: 1.0\r\nMixinConfigs: advantageorderprobe.mixins.json\r\n" + "Automatic-Module-Name: advantageorderprobe\r\n\r\n").encode() + jar = directory / JAR + archive(jar, files) + variants[mode] = dict(jar=str(jar), sha256=digest(jar), pins=str(plan), pinsSha256=digest(plan)) + if fingerprint(ROOT) != sources: + raise AssertionError("Order sidecar sources changed during build") + receipt = dict(scope="INSTRUMENTED_ORDER_TEST_ONLY", qualificationAllowed=False, profile=str(profile), + profileSha256=digest(profile), javaSha256=digest(java), sourceFingerprint=sources, + classpathFileSha256=digest(classpath_file), classpath={str(path): digest(path) for path in classpath}, + nativeEngineSha256=digest(engine), nativeClasses=str(native_classes), + nativeClassHashes=fingerprint(native_classes), variants=variants) + write_json(output / "build-receipt.json", receipt) + return receipt + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + for name in ("profile", "seed", "java", "output"): + parser.add_argument("--" + name, type=lambda value: Path(value).resolve(), required=True) + args = parser.parse_args() + print(json.dumps(build(args.profile, args.seed, args.java, args.output)["variants"], indent=2)) diff --git a/test-rig/order-probe/resources/META-INF/mods.toml b/test-rig/order-probe/resources/META-INF/mods.toml new file mode 100644 index 00000000..0534fc69 --- /dev/null +++ b/test-rig/order-probe/resources/META-INF/mods.toml @@ -0,0 +1,24 @@ +modLoader="javafml" +loaderVersion="[61,)" +license="All Rights Reserved" +showAsResourcePack=false + +[[mods]] +modId="advantageorderprobe" +version="1.0.0.121111" +displayName="Local Native61 Order Probe" +description="Local instrumented order test only. Not production qualification." + +[[dependencies.advantageorderprobe]] +modId="forge" +mandatory=true +versionRange="[61.1.0]" +ordering="NONE" +side="SERVER" + +[[dependencies.advantageorderprobe]] +modId="minecraft" +mandatory=true +versionRange="[1.21.11]" +ordering="NONE" +side="SERVER" diff --git a/test-rig/order-probe/resources/advantageorderprobe.mixins.json b/test-rig/order-probe/resources/advantageorderprobe.mixins.json new file mode 100644 index 00000000..7b981924 --- /dev/null +++ b/test-rig/order-probe/resources/advantageorderprobe.mixins.json @@ -0,0 +1,9 @@ +{ + "required": true, + "minVersion": "0.8.7", + "package": "zone.moddev.orderprobe.mixin", + "plugin": "zone.moddev.orderprobe.OrderProbePlugin", + "compatibilityLevel": "JAVA_21", + "mixins": [], + "injectors": { "defaultRequire": 1 } +} diff --git a/test-rig/order-probe/src/zone/moddev/orderprobe/OrderProbeAudit.java b/test-rig/order-probe/src/zone/moddev/orderprobe/OrderProbeAudit.java new file mode 100644 index 00000000..a5d6c06b --- /dev/null +++ b/test-rig/order-probe/src/zone/moddev/orderprobe/OrderProbeAudit.java @@ -0,0 +1,231 @@ +package zone.moddev.orderprobe; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.*; + +/** Audits exported woven bytecode. It never loads or changes a game target class. */ +public final class OrderProbeAudit { + private static final String REGION = "net/minecraft/world/level/chunk/storage/SimpleRegionStorage"; + private static final String LEVEL = "net/minecraft/world/level/storage/LevelStorageSource"; + private static final String WORLDGEN = "net/minecraft/server/level/WorldGenRegion"; + private static final String TAG = "Lnet/minecraft/nbt/CompoundTag;"; + + private static MethodNode method(ClassNode node, String name, String descriptor) { + List found = node.methods.stream().filter(row -> row.name.equals(name) + && (descriptor == null || row.desc.equals(descriptor))).toList(); + ProbeGuard.require(found.size() == 1, "missing/ambiguous method " + node.name + "." + name); + return found.getFirst(); + } + + private static List instructions(MethodNode method) { + return Arrays.stream(method.instructions.toArray()).filter(row -> row.getOpcode() >= 0).toList(); + } + + private static String codeHash(MethodNode method, String owner) throws Exception { + Map labels = new IdentityHashMap<>(); + int index = 0; + for (AbstractInsnNode row : method.instructions) { + if (row instanceof LabelNode label) labels.put(label, index); + if (row.getOpcode() >= 0) index++; + } + List code = new ArrayList<>(); + for (AbstractInsnNode row : instructions(method)) { + List item = new ArrayList<>(); + item.add(row.getOpcode()); + if (row instanceof MethodInsnNode call) item.addAll(List.of(call.owner.equals(owner) ? "SELF" : call.owner, call.name, call.desc, call.itf)); + else if (row instanceof FieldInsnNode field) item.addAll(List.of(field.owner.equals(owner) ? "SELF" : field.owner, field.name, field.desc)); + else if (row instanceof VarInsnNode variable) item.add(variable.var); + else if (row instanceof IntInsnNode integer) item.add(integer.operand); + else if (row instanceof TypeInsnNode type) item.add(type.desc.equals(owner) ? "SELF" : type.desc); + else if (row instanceof JumpInsnNode jump) item.add(labels.get(jump.label)); + else if (row instanceof LdcInsnNode constant) item.add(List.of(constant.cst.getClass().getName(), constant.cst.toString())); + else if (row instanceof IincInsnNode increment) item.addAll(List.of(increment.var, increment.incr)); + else if (!(row instanceof InsnNode)) throw new AssertionError("Unreviewed handler instruction " + row.getClass()); + code.add(item); + } + ProbeGuard.require(method.tryCatchBlocks.isEmpty(), "unreviewed handler exception path"); + return ProbeGuard.sha(new Gson().toJson(code).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private static ClassNode exported(Path exports, String name) throws Exception { + Path file = exports.resolve(name + ".class"); + ClassNode node = ProbeGuard.node(Files.readAllBytes(file)); + ProbeGuard.require(node.name.equals(name), "export class identity mismatch"); + return node; + } + + private static MethodNode handler(JsonObject pins, Path mods, ClassNode target, String mod, String suffix) throws Exception { + String mixinClass = (mod.equals("basepower") ? "zone.moddev.mc.basepower.mixin." : "zone.moddev.mc.basemetals.mixin.") + + target.name.substring(target.name.lastIndexOf('/') + 1) + "Mixin"; + String name = mod + "$" + suffix; + List matches = target.methods.stream().filter(row -> row.name.endsWith(name)).toList(); + ProbeGuard.require(matches.size() == 1, "missing/duplicate woven handler " + name); + MethodNode actual = matches.getFirst(); + AnnotationNode merged = ProbeGuard.annotation(actual.visibleAnnotations, "Lorg/spongepowered/asm/mixin/transformer/meta/MixinMerged;"); + if (merged == null) merged = ProbeGuard.annotation(actual.invisibleAnnotations, "Lorg/spongepowered/asm/mixin/transformer/meta/MixinMerged;"); + ProbeGuard.require(mixinClass.equals(ProbeGuard.value(merged, "mixin")), "woven handler origin differs " + name); + ProbeGuard.require(pins.getAsJsonObject("priorities").get(mod + ".mixins.json").getAsInt() + == ((Number) ProbeGuard.value(merged, "priority")).intValue(), "woven priority differs " + name); + Path jar = mods.resolve(pins.getAsJsonObject("mods").getAsJsonObject(mod).get("file").getAsString()); + ClassNode originalClass = ProbeGuard.node(ProbeGuard.entry(jar, mixinClass.replace('.', '/') + ".class")); + MethodNode original = method(originalClass, name, actual.desc); + if (suffix.equals("protectTerrain")) { + List source = instructions(original); + ProbeGuard.require(source.get(0) instanceof VarInsnNode receiver && receiver.getOpcode() == Opcodes.ALOAD && receiver.var == 0 + && source.get(1) instanceof TypeInsnNode cast && cast.getOpcode() == Opcodes.CHECKCAST && cast.desc.equals(target.name), + "reviewed worldgen receiver cast changed"); + // MixinTargetContext.transformTypeNode removes precisely this redundant + // ALOAD 0 / CHECKCAST target. Transform the expected source, not observed code. + original.instructions.remove(source.get(1)); + } + ProbeGuard.require(codeHash(original, originalClass.name).equals(codeHash(actual, target.name)), "woven handler body changed " + name); + if (!suffix.equals("protectTerrain")) { + ProbeGuard.require(instructions(actual).stream().noneMatch(row -> row instanceof MethodInsnNode call + && (call.name.equals("cancel") || call.name.equals("setReturnValue"))), "cooperative handler cancels " + name); + } + return actual; + } + + private static List sequence(MethodNode method, Map names) { + List result = new ArrayList<>(); + for (AbstractInsnNode row : instructions(method)) { + if (row instanceof MethodInsnNode call && names.containsKey(call.name)) result.add(names.get(call.name)); + } + return result; + } + + private static void pair(List values, String context) { + ProbeGuard.require(values.size() == 2 && new java.util.HashSet<>(values).equals(java.util.Set.of("basepower", "basemetals")), + "missing/duplicate callback pair " + context + ": " + values); + } + + private static boolean dominates(MethodNode method, int earlier, int later) { + List code = instructions(method); + Map labels = new IdentityHashMap<>(); + int index = 0; + for (AbstractInsnNode row : method.instructions) { + if (row instanceof LabelNode label) labels.put(label, index); + if (row.getOpcode() >= 0) index++; + } + // If later is reachable while removing earlier, earlier does not dominate it. + java.util.Set visited = new java.util.HashSet<>(); + java.util.ArrayDeque pending = new java.util.ArrayDeque<>(); + pending.add(0); + while (!pending.isEmpty()) { + int position = pending.removeFirst(); + if (position == earlier || position >= code.size() || !visited.add(position)) continue; + if (position == later) return false; + AbstractInsnNode row = code.get(position); + for (TryCatchBlockNode handler : method.tryCatchBlocks) { + if (position >= labels.get(handler.start) && position < labels.get(handler.end)) pending.add(labels.get(handler.handler)); + } + if (row instanceof JumpInsnNode jump) { + pending.add(labels.get(jump.label)); + if (row.getOpcode() != Opcodes.GOTO) pending.add(position + 1); + } else if (row instanceof TableSwitchInsnNode table) { + pending.add(labels.get(table.dflt)); table.labels.forEach(label -> pending.add(labels.get(label))); + } else if (row instanceof LookupSwitchInsnNode lookup) { + pending.add(labels.get(lookup.dflt)); lookup.labels.forEach(label -> pending.add(labels.get(label))); + } else if (row.getOpcode() != Opcodes.ATHROW && (row.getOpcode() < Opcodes.IRETURN || row.getOpcode() > Opcodes.RETURN)) { + pending.add(position + 1); + } + } + return true; + } + + public static JsonObject audit(JsonObject pins, Path runtime, Path exports, Path nativeClasses) throws Exception { + ProbeGuard.verifyProduction(pins, runtime.resolve("mods")); + ClassNode region = exported(exports, REGION), level = exported(exports, LEVEL), worldgen = exported(exports, WORLDGEN); + Map heads = new LinkedHashMap<>(), returns = new LinkedHashMap<>(), worldHeads = new LinkedHashMap<>(), terrain = new LinkedHashMap<>(); + for (String mod : List.of("basepower", "basemetals")) { + heads.put(handler(pins, runtime.resolve("mods"), region, mod, "prepareChunk").name, mod); + returns.put(handler(pins, runtime.resolve("mods"), region, mod, "finishChunk").name, mod); + worldHeads.put(handler(pins, runtime.resolve("mods"), level, mod, "prepareWorld").name, mod); + terrain.put(handler(pins, runtime.resolve("mods"), worldgen, mod, "protectTerrain").name, mod); + } + MethodNode staging = handler(pins, runtime.resolve("mods"), region, "basepower", "prepareInventories"); + MethodNode upgrade = method(region, "upgradeChunkTag", "(" + TAG + "I" + TAG + ")" + TAG); + List headOrder = sequence(upgrade, heads); + pair(headOrder, "region heads"); + List code = instructions(upgrade); + ClassNode nativeRegion = exported(nativeClasses, REGION); + MethodNode nativeUpgrade = method(nativeRegion, "upgradeChunkTag", upgrade.desc); + List nativeExceptions = nativeUpgrade.tryCatchBlocks.stream().map(row -> row.type).toList(); + ProbeGuard.require(nativeExceptions.equals(List.of("java/lang/Exception")) + && upgrade.tryCatchBlocks.stream().map(row -> row.type).toList().equals(nativeExceptions), + "native exception-handler coverage changed"); + int stagedAt = -1, getVersionAt = -1, lastHead = -1; + List headSites = new ArrayList<>(), returnSites = new ArrayList<>(); + List> returned = new ArrayList<>(); + List pendingReturns = new ArrayList<>(); + for (int i = 0; i < code.size(); i++) { + AbstractInsnNode row = code.get(i); + if (row instanceof MethodInsnNode call) { + if (heads.containsKey(call.name)) { lastHead = i; headSites.add(i); } + if (call.name.equals(staging.name)) { ProbeGuard.require(stagedAt < 0, "duplicate inventory staging"); stagedAt = i; } + if (call.owner.equals("net/minecraft/nbt/NbtUtils") && call.name.equals("getDataVersion")) { + ProbeGuard.require(getVersionAt < 0, "duplicate native data-version invocation"); getVersionAt = i; + } + if (returns.containsKey(call.name)) { pendingReturns.add(returns.get(call.name)); returnSites.add(i); } + ProbeGuard.require(!call.name.equals("isCancelled"), "unexpected cancellable branch in cooperative upgrade target"); + } + if (row.getOpcode() == Opcodes.ARETURN) { + pair(pendingReturns, "native return " + returned.size()); + for (int site : headSites) ProbeGuard.require(dominates(upgrade, site, i), "HEAD callback can be bypassed"); + for (int site : returnSites) ProbeGuard.require(dominates(upgrade, site, i), "RETURN callback can be bypassed"); + ProbeGuard.require(dominates(upgrade, returnSites.getFirst(), returnSites.getLast()), "return callbacks can execute out of order"); + returned.add(List.copyOf(pendingReturns)); pendingReturns.clear(); + returnSites.clear(); + } + } + ProbeGuard.require(stagedAt > lastHead && getVersionAt > stagedAt, "inventory staging is not after both HEAD callbacks and before native DFU version read"); + for (int site : headSites) ProbeGuard.require(dominates(upgrade, site, stagedAt), "HEAD callback can be bypassed before staging"); + ProbeGuard.require(dominates(upgrade, stagedAt, getVersionAt), "inventory staging can be bypassed"); + ProbeGuard.require(dominates(upgrade, headSites.getFirst(), headSites.getLast()), "HEAD callbacks can execute out of order"); + long nativeReturns = instructions(nativeUpgrade).stream().filter(row -> row.getOpcode() == Opcodes.ARETURN).count(); + ProbeGuard.require(nativeReturns == 2 && returned.size() == nativeReturns && pendingReturns.isEmpty(), "native return coverage changed"); + List levelOrder = sequence(method(level, "readLevelDataTagFixed", null), worldHeads); + List worldgenOrder = sequence(method(worldgen, "ensureCanWrite", null), terrain); + pair(levelOrder, "world heads"); pair(worldgenOrder, "worldgen heads"); + JsonObject result = new JsonObject(); + result.addProperty("status", "PASS"); result.addProperty("scope", "INSTRUMENTED_ORDER_TEST_ONLY"); + result.addProperty("qualificationAllowed", false); result.addProperty("mode", pins.get("mode").getAsString()); + result.add("regionHeads", new Gson().toJsonTree(headOrder)); + result.add("regionReturns", new Gson().toJsonTree(returned)); + result.add("worldHeads", new Gson().toJsonTree(levelOrder)); + result.add("worldgenHeads", new Gson().toJsonTree(worldgenOrder)); + result.addProperty("allHandlerBodiesMatchPinnedProduction", true); + result.add("expectedWeavingTransforms", new Gson().toJsonTree(List.of( + "basepower$protectTerrain: redundant receiver CHECKCAST elided by Mixin0.8.7", + "basemetals$protectTerrain: redundant receiver CHECKCAST elided by Mixin0.8.7"))); + result.addProperty("nativeReturnsCovered", nativeReturns); + result.addProperty("exceptionEdgesChecked", true); + JsonObject hashes = new JsonObject(); + for (String name : List.of(REGION, LEVEL, WORLDGEN)) hashes.addProperty(name, ProbeGuard.sha(Files.readAllBytes(exports.resolve(name + ".class")))); + result.add("exportedClassSha256", hashes); + return result; + } + + public static void main(String[] args) throws Exception { + JsonObject pins = ProbeGuard.read(Path.of(args[1])); + if (args[0].equals("guard")) { + ProbeGuard.verifyProduction(pins, Path.of(args[2])); + System.out.println("ORDER_PROBE_GUARD_PASS"); + } else if (args[0].equals("audit")) { + JsonObject result = audit(pins, Path.of(args[2]), Path.of(args[3]), Path.of(args[4])); + Files.writeString(Path.of(args[5]), new Gson().toJson(result) + "\n", StandardOpenOption.CREATE_NEW); + System.out.println(new Gson().toJson(result)); + } else throw new AssertionError("Unknown audit command"); + } +} diff --git a/test-rig/order-probe/src/zone/moddev/orderprobe/OrderProbeMod.java b/test-rig/order-probe/src/zone/moddev/orderprobe/OrderProbeMod.java new file mode 100644 index 00000000..bc5c8d05 --- /dev/null +++ b/test-rig/order-probe/src/zone/moddev/orderprobe/OrderProbeMod.java @@ -0,0 +1,14 @@ +package zone.moddev.orderprobe; + +@net.minecraftforge.fml.common.Mod("advantageorderprobe") +public final class OrderProbeMod { + public OrderProbeMod() { + try { + // Old fully-generated worlds need not use worldgen. Load, but do not + // initialize or invoke, the existing target so its weaving is auditable. + Class.forName("net.minecraft.server.level.WorldGenRegion", false, getClass().getClassLoader()); + } catch (ClassNotFoundException failure) { + throw new AssertionError("Native order audit target missing", failure); + } + } +} diff --git a/test-rig/order-probe/src/zone/moddev/orderprobe/OrderProbePlugin.java b/test-rig/order-probe/src/zone/moddev/orderprobe/OrderProbePlugin.java new file mode 100644 index 00000000..f1e4f7fb --- /dev/null +++ b/test-rig/order-probe/src/zone/moddev/orderprobe/OrderProbePlugin.java @@ -0,0 +1,122 @@ +package zone.moddev.orderprobe; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.MixinEnvironment; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; + +public final class OrderProbePlugin implements IMixinConfigPlugin { + private static Field field(Class type, String name) throws Exception { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + @Override public void onLoad(String mixinPackage) { + try { + byte[] raw; + try (InputStream stream = getClass().getResourceAsStream("/order-probe-pins.json")) { + ProbeGuard.require(stream != null, "missing sealed sidecar pins"); + raw = stream.readAllBytes(); + } + JsonObject pins = JsonParser.parseString(new String(raw, StandardCharsets.UTF_8)).getAsJsonObject(); + ProbeGuard.require(MixinEnvironment.getCurrentEnvironment().getVersion().equals("0.8.7"), "native Mixin version drift"); + for (var pin : pins.getAsJsonObject("mixinClasses").entrySet()) { + try (InputStream stream = MixinEnvironment.class.getResourceAsStream("/" + pin.getKey().replace('.', '/') + ".class")) { + ProbeGuard.require(stream != null && ProbeGuard.sha(stream.readAllBytes()).equals(pin.getValue().getAsString()), + "native Mixin implementation drift: " + pin.getKey()); + } + } + Path runtime = Path.of("").toRealPath(); + ProbeGuard.verifyProduction(pins, runtime.resolve("mods")); + Class wrapper = Class.forName("org.spongepowered.asm.mixin.transformer.Config", false, MixinEnvironment.class.getClassLoader()); + Class configType = Class.forName("org.spongepowered.asm.mixin.transformer.MixinConfig", false, wrapper.getClassLoader()); + @SuppressWarnings("unchecked") Map configs = (Map) field(wrapper, "allConfigs").get(null); + Map actual = new LinkedHashMap<>(); + for (var config : configs.entrySet()) actual.put(config.getKey(), wrapper.getMethod("getConfig").invoke(config.getValue())); + @SuppressWarnings("unchecked") Set preparedClasses = (Set) field(configType, "globalMixinList").get(null); + for (var row : pins.getAsJsonObject("configs").entrySet()) { + for (String expectedClass : row.getValue().getAsJsonObject().getAsJsonObject("classes").keySet()) { + ProbeGuard.require(!preparedClasses.contains(expectedClass), "child mixin already prepared elsewhere"); + int declarations = 0; + for (Object config : actual.values()) { + String prefix = (String) field(configType, "mixinPackage").get(config); + for (String collection : List.of("mixinClasses", "mixinClassesClient", "mixinClassesServer")) { + Object list = field(configType, collection).get(config); + if (list == null) continue; + for (Object shortName : (List) list) { + String fullName = prefix + (prefix.endsWith(".") ? "" : ".") + shortName; + if (expectedClass.equals(fullName)) declarations++; + } + } + } + ProbeGuard.require(declarations == 1, "missing/duplicate production mixin declaration: " + expectedClass); + } + } + Map before = new LinkedHashMap<>(), after = new LinkedHashMap<>(); + Field priority = field(configType, "mixinPriority"); + for (var config : actual.entrySet()) before.put(config.getKey(), priority.getInt(config.getValue())); + for (var row : pins.getAsJsonObject("configs").entrySet()) { + JsonObject pin = row.getValue().getAsJsonObject(); + Object config = actual.get(row.getKey()); + ProbeGuard.require(config != null && config.getClass() == configType, "missing/duplicate child config " + row.getKey()); + ProbeGuard.require(!field(configType, "prepared").getBoolean(config), "child config already prepared"); + ProbeGuard.require(priority.getInt(config) == 1000, "unexpected native default mixin priority"); + String packageName = pin.get("package").getAsString(); + Object observedPackage = field(configType, "mixinPackage").get(config); + ProbeGuard.require(observedPackage.equals(packageName) || observedPackage.equals(packageName + "."), "config package differs"); + ProbeGuard.require(field(configType, "mixinClasses").get(config).equals(ProbeGuard.strings(pin.getAsJsonArray("mixins"))), "config mixin list differs"); + for (String name : List.of("mixinClassesClient", "mixinClassesServer")) { + Object value = field(configType, name).get(config); + ProbeGuard.require(value == null || ((List) value).isEmpty(), "unexpected side-specific child mixins"); + } + } + JsonObject requested = pins.getAsJsonObject("priorities"); + ProbeGuard.require(requested.keySet().equals(pins.getAsJsonObject("configs").keySet()), "priority scope differs"); + String mode = pins.get("mode").getAsString(); + ProbeGuard.require(mode.equals("power-low") || mode.equals("metals-low"), "unknown order mode"); + ProbeGuard.require(requested.get("basepower.mixins.json").getAsInt() == (mode.equals("power-low") ? 900 : 1100) + && requested.get("basemetals.mixins.json").getAsInt() == (mode.equals("power-low") ? 1100 : 900), "unapproved priority pair"); + for (var row : requested.entrySet()) priority.setInt(actual.get(row.getKey()), row.getValue().getAsInt()); + for (var config : actual.entrySet()) after.put(config.getKey(), priority.getInt(config.getValue())); + for (String name : before.keySet()) { + ProbeGuard.require(after.get(name).equals(requested.has(name) ? requested.get(name).getAsInt() : before.get(name)), "unrelated priority mutation"); + } + Map proof = new LinkedHashMap<>(); + proof.put("status", "INSTRUMENTED_ORDER_TEST_ONLY"); + proof.put("qualificationAllowed", false); + proof.put("mode", pins.get("mode").getAsString()); + proof.put("runtime", runtime.toString()); + proof.put("pid", ProcessHandle.current().pid()); + proof.put("pinsSha256", ProbeGuard.sha(raw)); + proof.put("preparedBefore", false); + proof.put("before", before); + proof.put("after", after); + Files.writeString(runtime.resolve("native61-order-plugin-" + ProcessHandle.current().pid() + ".json"), + new Gson().toJson(proof) + "\n", StandardOpenOption.CREATE_NEW); + System.out.println("NATIVE61_ORDER_PROBE_PRIORITIES " + pins.get("mode").getAsString()); + } catch (Throwable failure) { + throw new org.spongepowered.asm.launch.MixinInitialisationError("ORDER_PROBE guard failed", failure); + } + } + + @Override public String getRefMapperConfig() { return null; } + @Override public boolean shouldApplyMixin(String target, String mixin) { throw new AssertionError("Order sidecar must declare no target mixins"); } + @Override public void acceptTargets(Set own, Set other) { ProbeGuard.require(own.isEmpty(), "sidecar unexpectedly targets game classes"); } + @Override public List getMixins() { return List.of(); } + @Override public void preApply(String target, ClassNode node, String mixin, IMixinInfo info) { throw new AssertionError("No sidecar transforms allowed"); } + @Override public void postApply(String target, ClassNode node, String mixin, IMixinInfo info) { throw new AssertionError("No sidecar transforms allowed"); } +} diff --git a/test-rig/order-probe/src/zone/moddev/orderprobe/ProbeGuard.java b/test-rig/order-probe/src/zone/moddev/orderprobe/ProbeGuard.java new file mode 100644 index 00000000..cae381f6 --- /dev/null +++ b/test-rig/order-probe/src/zone/moddev/orderprobe/ProbeGuard.java @@ -0,0 +1,99 @@ +package zone.moddev.orderprobe; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.zip.ZipFile; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.tree.AnnotationNode; +import org.objectweb.asm.tree.ClassNode; + +public final class ProbeGuard { + private ProbeGuard() {} + + public static String sha(byte[] bytes) throws Exception { + return HexFormat.of().withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } + + public static void require(boolean condition, String message) { + if (!condition) throw new AssertionError("ORDER_PROBE: " + message); + } + + public static byte[] entry(Path jar, String name) throws Exception { + try (ZipFile zip = new ZipFile(jar.toFile())) { + require(zip.stream().filter(row -> row.getName().equals(name)).count() == 1, "missing/duplicate jar entry " + name); + try (InputStream stream = zip.getInputStream(zip.getEntry(name))) { return stream.readAllBytes(); } + } + } + + public static ClassNode node(byte[] bytes) { + ClassNode node = new ClassNode(); + new ClassReader(bytes).accept(node, 0); + return node; + } + + public static AnnotationNode annotation(List values, String descriptor) { + return values == null ? null : values.stream().filter(value -> value.desc.equals(descriptor)).findFirst().orElse(null); + } + + public static Object value(AnnotationNode annotation, String key) { + if (annotation == null || annotation.values == null) return null; + int index = annotation.values.indexOf(key); + return index < 0 ? null : annotation.values.get(index + 1); + } + + public static JsonObject read(Path path) throws Exception { + return JsonParser.parseString(Files.readString(path)).getAsJsonObject(); + } + + public static void verifyProduction(JsonObject pins, Path mods) throws Exception { + require(pins.get("format").getAsInt() == 1 && pins.get("mixinVersion").getAsString().equals("0.8.7"), "pin version drift"); + require(pins.getAsJsonObject("mods").keySet().equals(java.util.Set.of("basepower", "basemetals", "basesteam", "baseelectric", "orespawn", "advantageworkstest")), "six-jar pin scope changed"); + require(pins.getAsJsonObject("configs").keySet().equals(java.util.Set.of("basepower.mixins.json", "basemetals.mixins.json")), "config pin scope changed"); + for (var row : pins.getAsJsonObject("mods").entrySet()) { + JsonObject pin = row.getValue().getAsJsonObject(); + Path jar = mods.resolve(pin.get("file").getAsString()); + require(sha(Files.readAllBytes(jar)).equals(pin.get("sha256").getAsString()), "production jar drift: " + row.getKey()); + } + for (var row : pins.getAsJsonObject("configs").entrySet()) { + JsonObject pin = row.getValue().getAsJsonObject(); + String mod = pin.get("mod").getAsString(); + int copies = 0; + try (var candidates = Files.list(mods)) { + for (Path candidate : candidates.filter(path -> path.getFileName().toString().endsWith(".jar")).toList()) { + try (ZipFile zip = new ZipFile(candidate.toFile())) { + copies += (int) zip.stream().filter(item -> item.getName().equals(row.getKey())).count(); + } + } + } + require(copies == 1, "missing/duplicate child config resource " + row.getKey()); + Path jar = mods.resolve(pins.getAsJsonObject("mods").getAsJsonObject(mod).get("file").getAsString()); + byte[] config = entry(jar, row.getKey()); + require(sha(config).equals(pin.get("sha256").getAsString()), "config drift: " + row.getKey()); + JsonObject document = JsonParser.parseString(new String(config, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + require(!document.has("plugin") && !document.has("priority") && !document.has("mixinPriority"), "unexpected child config priority/plugin"); + require(document.get("required").getAsBoolean(), "child config not required"); + for (var type : pin.getAsJsonObject("classes").entrySet()) { + byte[] bytes = entry(jar, type.getKey().replace('.', '/') + ".class"); + require(sha(bytes).equals(type.getValue().getAsString()), "mixin body drift: " + type.getKey()); + ClassNode node = node(bytes); + AnnotationNode mixin = annotation(node.invisibleAnnotations, "Lorg/spongepowered/asm/mixin/Mixin;"); + if (mixin == null) mixin = annotation(node.visibleAnnotations, "Lorg/spongepowered/asm/mixin/Mixin;"); + require(mixin != null && value(mixin, "priority") == null, "explicit class priority or missing @Mixin: " + type.getKey()); + } + } + } + + public static List strings(JsonArray array) { + List result = new ArrayList<>(); + array.forEach(value -> result.add(value.getAsString())); + return result; + } +} diff --git a/test-rig/order-probe/test/BodyEvidenceDump.java b/test-rig/order-probe/test/BodyEvidenceDump.java new file mode 100644 index 00000000..9bfdd4f6 --- /dev/null +++ b/test-rig/order-probe/test/BodyEvidenceDump.java @@ -0,0 +1,62 @@ +package zone.moddev.orderprobe; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.*; + +/** Read-only, reproducible per-handler hashes from the already sealed real exports. */ +public final class BodyEvidenceDump { + public static void main(String[] args) throws Exception { + JsonObject pins = ProbeGuard.read(Path.of(args[0])); + Path runtime = Path.of(args[1]), exports = Path.of(args[2]); + ProbeGuard.verifyProduction(pins, runtime.resolve("mods")); + Method hash = OrderProbeAudit.class.getDeclaredMethod("codeHash", MethodNode.class, String.class); + hash.setAccessible(true); + Map rows = new LinkedHashMap<>(); + for (String target : List.of("net/minecraft/world/level/chunk/storage/SimpleRegionStorage", + "net/minecraft/world/level/storage/LevelStorageSource", "net/minecraft/server/level/WorldGenRegion")) { + ClassNode woven = ProbeGuard.node(Files.readAllBytes(exports.resolve(target + ".class"))); + for (String mod : List.of("basepower", "basemetals")) { + String shortName = target.substring(target.lastIndexOf('/') + 1); + String sourceName = "zone/moddev/mc/" + mod + "/mixin/" + shortName + "Mixin"; + Path jar = runtime.resolve("mods").resolve(pins.getAsJsonObject("mods").getAsJsonObject(mod).get("file").getAsString()); + ClassNode source = ProbeGuard.node(ProbeGuard.entry(jar, sourceName + ".class")); + for (String suffix : List.of("prepareChunk", "finishChunk", "prepareInventories", "prepareWorld", "protectTerrain")) { + String name = mod + "$" + suffix; + List originals = source.methods.stream().filter(row -> row.name.equals(name)).toList(); + if (originals.isEmpty()) continue; + ProbeGuard.require(originals.size() == 1, "duplicate source handler"); + MethodNode original = originals.getFirst(); + String rawHash = (String) hash.invoke(null, original, source.name); + String transformation = "none"; + if (suffix.equals("protectTerrain")) { + var code = Arrays.stream(original.instructions.toArray()).filter(row -> row.getOpcode() >= 0).toList(); + ProbeGuard.require(code.get(1) instanceof TypeInsnNode cast && cast.getOpcode() == Opcodes.CHECKCAST + && cast.desc.equals(target), "reviewed source receiver cast changed"); + original.instructions.remove(code.get(1)); + transformation = "exact receiver CHECKCAST elision: MixinTargetContext.transformTypeNode"; + } + MethodNode observed = woven.methods.stream().filter(row -> row.name.endsWith(name)).findFirst().orElseThrow(); + String expected = (String) hash.invoke(null, original, source.name); + String actual = (String) hash.invoke(null, observed, woven.name); + ProbeGuard.require(expected.equals(actual), "per-handler body mismatch " + name); + rows.put(name, Map.of("sourceClass", sourceName, "sourceRawCodeSha256", rawHash, + "expectedWovenCodeSha256", expected, "observedCodeSha256", actual, + "expectedTransformation", transformation, "generatedHandler", observed.name)); + } + } + } + ProbeGuard.require(rows.size() == 9, "missing required handler evidence"); + Files.writeString(Path.of(args[3]), new Gson().toJson(rows) + "\n", StandardOpenOption.CREATE_NEW); + System.out.println("NINE_PINNED_HANDLER_BODIES_MATCH"); + } +} diff --git a/test-rig/order-probe/test/WovenNegativeControl.java b/test-rig/order-probe/test/WovenNegativeControl.java new file mode 100644 index 00000000..24dca79e --- /dev/null +++ b/test-rig/order-probe/test/WovenNegativeControl.java @@ -0,0 +1,81 @@ +package zone.moddev.orderprobe; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import org.objectweb.asm.*; +import org.objectweb.asm.tree.*; + +/** Writes new synthetic audit fixtures only, never jars or existing target files. */ +public final class WovenNegativeControl { + public static void main(String[] args) throws Exception { + Path input = Path.of(args[0]), output = Path.of(args[1]); + if (args[0].equals("fixture")) { + if (!output.toString().endsWith(".class") || Files.exists(output)) throw new AssertionError("New fixture class required"); + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT, "fixture/ProbeMixin", null, "java/lang/Object", null); + if (!args[2].equals("no-annotation")) { + AnnotationVisitor annotation = writer.visitAnnotation("Lorg/spongepowered/asm/mixin/Mixin;", false); + AnnotationVisitor values = annotation.visitArray("value"); values.visit(null, Type.getType("Ljava/lang/Object;")); values.visitEnd(); + if (args[2].equals("explicit-priority")) annotation.visit("priority", 1234); + annotation.visitEnd(); + } + writer.visitEnd(); Files.createDirectories(output.getParent()); + Files.write(output, writer.toByteArray(), StandardOpenOption.CREATE_NEW); return; + } + if (!input.toString().endsWith(".class") || !output.toString().endsWith(".class") || Files.exists(output)) { + throw new AssertionError("New .class fixture output required; jar/existing-file writes forbidden"); + } + ClassNode node = ProbeGuard.node(Files.readAllBytes(input)); + String mutation = args[2]; + if (mutation.startsWith("worldgen-")) { + MethodNode target = node.methods.stream().filter(row -> row.name.endsWith("basepower$protectTerrain")).findFirst().orElseThrow(); + if (mutation.equals("worldgen-body")) target.instructions.insert(new InsnNode(Opcodes.NOP)); + else if (mutation.equals("worldgen-true")) { + AbstractInsnNode zero = Arrays.stream(target.instructions.toArray()).filter(row -> row.getOpcode() == Opcodes.ICONST_0).findFirst().orElseThrow(); + target.instructions.set(zero, new InsnNode(Opcodes.ICONST_1)); + } else if (mutation.equals("worldgen-extra-cast")) { + AbstractInsnNode receiver = Arrays.stream(target.instructions.toArray()).filter(row -> row instanceof VarInsnNode var + && var.getOpcode() == Opcodes.ALOAD && var.var == 0).findFirst().orElseThrow(); + target.instructions.insert(receiver, new TypeInsnNode(Opcodes.CHECKCAST, node.name)); + } else throw new AssertionError("Unknown worldgen mutation"); + write(output, node); return; + } + MethodNode upgrade = node.methods.stream().filter(row -> row.name.equals("upgradeChunkTag") + && row.desc.equals("(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;)Lnet/minecraft/nbt/CompoundTag;")).findFirst().orElseThrow(); + MethodNode head = node.methods.stream().filter(row -> row.name.endsWith("basepower$prepareChunk")).findFirst().orElseThrow(); + if (mutation.equals("body")) head.instructions.insert(new InsnNode(Opcodes.NOP)); + else if (mutation.equals("constant-body")) { + MethodNode finish = node.methods.stream().filter(row -> row.name.endsWith("basepower$finishChunk")).findFirst().orElseThrow(); + InsnList extra = new InsnList(); extra.add(new InsnNode(Opcodes.ICONST_1)); extra.add(new InsnNode(Opcodes.POP)); + finish.instructions.insert(extra); + } else if (mutation.equals("duplicate-handler")) { + MethodNode duplicate = new MethodNode(head.access, head.name, head.desc, head.signature, null); + head.accept(duplicate); node.methods.add(duplicate); + } else if (mutation.equals("priority")) { + AnnotationNode merged = ProbeGuard.annotation(head.visibleAnnotations, "Lorg/spongepowered/asm/mixin/transformer/meta/MixinMerged;"); + if (merged == null) merged = ProbeGuard.annotation(head.invisibleAnnotations, "Lorg/spongepowered/asm/mixin/transformer/meta/MixinMerged;"); + merged.values.set(merged.values.indexOf("priority") + 1, 1234); + } else if (mutation.equals("early-return")) { + InsnList extra = new InsnList(); extra.add(new InsnNode(Opcodes.ACONST_NULL)); extra.add(new InsnNode(Opcodes.ARETURN)); + upgrade.instructions.insert(extra); + } else { + MethodInsnNode call = Arrays.stream(upgrade.instructions.toArray()).filter(row -> row instanceof MethodInsnNode method + && method.name.endsWith(mutation.equals("missing-return") ? "basepower$finishChunk" : "basepower$prepareChunk")) + .map(MethodInsnNode.class::cast).findFirst().orElseThrow(); + if (mutation.equals("missing-head") || mutation.equals("missing-return")) upgrade.instructions.remove(call); + else if (mutation.equals("bypass-head")) { + LabelNode after = new LabelNode(); upgrade.instructions.insert(call, after); + upgrade.instructions.insert(new JumpInsnNode(Opcodes.GOTO, after)); + } else throw new AssertionError("Unknown mutation " + mutation); + } + write(output, node); + } + + private static void write(Path output, ClassNode node) throws Exception { + Files.createDirectories(output.getParent()); + ClassWriter writer = new ClassWriter(0); node.accept(writer); + Files.write(output, writer.toByteArray(), StandardOpenOption.CREATE_NEW); + } +} diff --git a/test-rig/order-probe/test/retain_final_evidence.py b/test-rig/order-probe/test/retain_final_evidence.py new file mode 100644 index 00000000..beaa0e64 --- /dev/null +++ b/test-rig/order-probe/test/retain_final_evidence.py @@ -0,0 +1,68 @@ +"""Add read-only per-body hashes and javap views to the completed instrumented proof.""" + +import argparse +import os +from pathlib import Path +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT.parent / "migration")) +from matrix import digest, fingerprint, write_json +import native12111 as native + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("build-receipt", "order-receipt", "java", "output"): + parser.add_argument("--" + name, type=lambda value: Path(value).resolve(), required=True) + args = parser.parse_args() + build, proof = native.read_json(args.build_receipt), native.read_json(args.order_receipt) + if proof["status"] != "PASS" or proof["qualificationAllowed"] is not False: + raise AssertionError("Completed instrumented proof required") + args.output.mkdir(parents=True, exist_ok=False) + classes = args.output / "test-classes" + classes.mkdir() + cp = os.pathsep.join([build["variants"]["power-low"]["jar"], *build["classpath"]]) + compiled = subprocess.run([str(args.java.with_name("javac.exe" if os.name == "nt" else "javac")), "-proc:none", "--release", "21", + "-cp", cp, "-d", str(classes), str(ROOT / "test/BodyEvidenceDump.java")], + env=native.native_launch_environment(), capture_output=True, text=True) + (args.output / "javac.log").write_text(compiled.stdout + compiled.stderr) + if compiled.returncode: + raise AssertionError("Body evidence tool compilation failed") + receipts = {} + for mode, campaign in proof["modes"].items(): + variant = build["variants"][mode] + cp = os.pathsep.join([str(classes), variant["jar"], *build["classpath"]]) + for boot in campaign["observations"]: + runtime = Path(boot["runtime"]) + exports = runtime / "order-evidence" / boot["label"] / "export" + before = fingerprint(exports) + output = args.output / mode / boot["label"] + output.mkdir(parents=True) + result = subprocess.run([str(args.java), "-cp", cp, "zone.moddev.orderprobe.BodyEvidenceDump", variant["pins"], + str(runtime), str(exports), str(output / "handler-bodies.json")], + env=native.native_launch_environment(), capture_output=True, text=True) + (output / "body-audit.log").write_text(result.stdout + result.stderr) + if result.returncode: + raise AssertionError("Per-body evidence mismatch") + for name in ("SimpleRegionStorage", "LevelStorageSource", "WorldGenRegion"): + matches = list(exports.rglob(name + ".class")) + if len(matches) != 1: + raise AssertionError("Missing/duplicate export") + dumped = subprocess.run([str(args.java.with_name("javap.exe" if os.name == "nt" else "javap")), "-p", "-c", str(matches[0])], + env=native.native_launch_environment(), capture_output=True, text=True) + if dumped.returncode: + raise AssertionError("Raw bytecode view failed") + (output / (name + ".javap.txt")).write_text(dumped.stdout + dumped.stderr, encoding="utf-8") + if before != fingerprint(exports): + raise AssertionError("Actual exports changed during read-only evidence retention") + receipts[mode + "/" + boot["label"]] = dict(bodyEvidenceSha256=digest(output / "handler-bodies.json"), actualExportsUnchanged=True, + files=fingerprint(output)) + write_json(args.output / "evidence-index.json", dict(status="PASS", scope="INSTRUMENTED_ORDER_TEST_ONLY", qualificationAllowed=False, + orderReceiptSha256=digest(args.order_receipt), boots=receipts)) + print("Retained all six boots: nine handler hashes and three raw bytecode views per boot") + + +if __name__ == "__main__": + main() diff --git a/test-rig/order-probe/test/run_guard_controls.py b/test-rig/order-probe/test/run_guard_controls.py new file mode 100644 index 00000000..23a7d38a --- /dev/null +++ b/test-rig/order-probe/test/run_guard_controls.py @@ -0,0 +1,110 @@ +"""Exercise Java production-pin guards using synthetic jars, never modified child jars.""" + +import argparse +import copy +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +from zipfile import ZipFile + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT.parent / "migration")) +from matrix import digest, write_json +import native12111 as native +from native12111_order_probe import MOD_IDS, SCOPE + + +def sha(data): + return hashlib.sha256(data).hexdigest().upper() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("build-receipt", "java", "output"): + parser.add_argument("--" + name, type=lambda value: Path(value).resolve(), required=True) + args = parser.parse_args() + build = native.read_json(args.build_receipt) + variant = build["variants"]["power-low"] + args.output.mkdir(parents=True, exist_ok=False) + classes = args.output / "test-classes" + classes.mkdir() + cp = os.pathsep.join([variant["jar"], *build["classpath"]]) + compile_result = subprocess.run([str(args.java.with_name("javac.exe" if os.name == "nt" else "javac")), "-proc:none", "--release", "21", + "-cp", cp, "-d", str(classes), str(ROOT / "test/WovenNegativeControl.java")], + env=native.native_launch_environment(), capture_output=True, text=True) + (args.output / "javac.log").write_text(compile_result.stdout + compile_result.stderr) + if compile_result.returncode: + raise AssertionError("Synthetic guard fixture compiler failed") + bodies = {} + for kind in ("no-priority", "explicit-priority", "no-annotation"): + path = args.output / (kind + ".class") + result = subprocess.run([str(args.java), "-cp", os.pathsep.join([str(classes), cp]), + "zone.moddev.orderprobe.WovenNegativeControl", "fixture", str(path), kind], + env=native.native_launch_environment(), capture_output=True, text=True) + if result.returncode: + raise AssertionError("Synthetic guard fixture generation failed: " + result.stderr) + bodies[kind] = path.read_bytes() + observations = {} + for case in ("baseline", "jar-hash", "class-hash", "config-hash", "missing-config", "duplicate-config", + "not-required", "config-priority", "explicit-class-priority", "missing-mixin-annotation", "mixin-version"): + folder = args.output / case + mods = folder / "synthetic-mods" + mods.mkdir(parents=True) + pins = dict(format=1, mixinVersion="0.8.7", mods={}, configs={}) + raw_configs = {} + for mod in sorted(MOD_IDS): + files = {} + if mod in ("basepower", "basemetals"): + name = mod + ".mixins.json" + config = dict(required=not (case == "not-required" and mod == "basepower"), package="fixture", mixins=["ProbeMixin"]) + if case == "config-priority" and mod == "basepower": + config["mixinPriority"] = 1234 + body_kind = ("explicit-priority" if case == "explicit-class-priority" else "no-annotation" if case == "missing-mixin-annotation" else "no-priority") + body = bodies[body_kind if mod == "basepower" else "no-priority"] + raw = json.dumps(config).encode() + raw_configs[name] = raw + if not (case == "missing-config" and mod == "basepower"): + files[name] = raw + files["fixture/ProbeMixin.class"] = body + pins["configs"][name] = dict(mod=mod, sha256=sha(raw), classes={"fixture.ProbeMixin": sha(body)}) + jar = mods / ("SYNTHETIC-" + mod + ".jar") + with ZipFile(jar, "x") as archive: + for name, data in files.items(): + archive.writestr(name, data) + pins["mods"][mod] = dict(file=jar.name, sha256=digest(jar)) + if case == "jar-hash": + pins["mods"]["basepower"]["sha256"] = "0" * 64 + elif case == "class-hash": + pins["configs"]["basepower.mixins.json"]["classes"]["fixture.ProbeMixin"] = "0" * 64 + elif case == "config-hash": + pins["configs"]["basepower.mixins.json"]["sha256"] = "0" * 64 + elif case == "duplicate-config": + with ZipFile(mods / "SYNTHETIC-extra.jar", "x") as archive: + archive.writestr("basepower.mixins.json", raw_configs["basepower.mixins.json"]) + elif case == "mixin-version": + pins["mixinVersion"] = "0.8.6" + plan = folder / "pins.json" + write_json(plan, pins) + checked = subprocess.run([str(args.java), "-cp", cp, "zone.moddev.orderprobe.OrderProbeAudit", "guard", str(plan), str(mods)], + env=native.native_launch_environment(), capture_output=True, text=True) + (folder / "guard.log").write_text(checked.stdout + checked.stderr) + if case == "baseline": + if checked.returncode or "ORDER_PROBE_GUARD_PASS" not in checked.stdout: + raise AssertionError("Unchanged synthetic guard baseline failed") + observations[case] = "PASS" + else: + if checked.returncode == 0 or "java.lang.AssertionError: ORDER_PROBE:" not in checked.stderr: + raise AssertionError("Guard mutation did not fail closed: " + case) + observations[case] = "REJECTED" + receipt = dict(status="PASS", scope=SCOPE, qualificationAllowed=False, + fixtureScope="Synthetic jars only; no production child jar copied or mutated", controls=observations, + buildReceiptSha256=digest(args.build_receipt), sourceSha256=digest(Path(__file__))) + write_json(args.output / "guard-controls.json", receipt) + print(json.dumps(receipt, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/test-rig/order-probe/test/run_woven_controls.py b/test-rig/order-probe/test/run_woven_controls.py new file mode 100644 index 00000000..add21d88 --- /dev/null +++ b/test-rig/order-probe/test/run_woven_controls.py @@ -0,0 +1,76 @@ +"""Negative controls against read-only real woven exports; mutations are new .class fixtures only.""" + +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT.parent / "migration")) +from matrix import digest, fingerprint, write_json +import native12111 as native +import native12111_order_probe as probe + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("build-receipt", "runtime", "exports", "java", "output"): + parser.add_argument("--" + name, type=lambda value: Path(value).resolve(), required=True) + args = parser.parse_args() + build = native.read_json(args.build_receipt) + variant = build["variants"]["power-low"] + native.validate_hash(Path(variant["jar"]), variant["sha256"]) + args.output.mkdir(parents=True, exist_ok=False) + before = fingerprint(args.exports) + production_before = fingerprint(args.runtime / "mods") + utility = args.output / "test-classes" + utility.mkdir() + cp = os.pathsep.join([variant["jar"], *build["classpath"]]) + command = [str(args.java.with_name("javac.exe" if os.name == "nt" else "javac")), "-proc:none", "--release", "21", + "-cp", cp, "-d", str(utility), str(ROOT / "test/WovenNegativeControl.java")] + compiled = subprocess.run(command, env=native.native_launch_environment(), capture_output=True, text=True) + (args.output / "javac.log").write_text(compiled.stdout + compiled.stderr) + if compiled.returncode: + raise AssertionError("Negative fixture utility compilation failed") + results = {"unmodified": probe.audit_command(args.java, build, variant, args.runtime, args.exports, args.output / "baseline.json")["status"]} + for mutation in ("body", "constant-body", "duplicate-handler", "priority", "early-return", "missing-head", + "missing-return", "bypass-head", "worldgen-body", "worldgen-true", "worldgen-extra-cast"): + folder = args.output / mutation + exports = folder / "export" + shutil.copytree(args.exports, exports) + relative = ("net/minecraft/server/level/WorldGenRegion.class" if mutation.startswith("worldgen-") + else "net/minecraft/world/level/chunk/storage/SimpleRegionStorage.class") + original = args.exports / relative + mutant = folder / "mutant" / relative + changed = subprocess.run([str(args.java), "-cp", os.pathsep.join([str(utility), cp]), + "zone.moddev.orderprobe.WovenNegativeControl", str(original), str(mutant), mutation], + env=native.native_launch_environment(), capture_output=True, text=True) + (folder / "fixture.log").write_text(changed.stdout + changed.stderr) + if changed.returncode or not mutant.is_file(): + raise AssertionError("Fixture mutation did not execute: " + mutation) + # Only a newly created derivative export is replaced, never a live export or jar. + shutil.copy2(mutant, exports / relative) + try: + probe.audit_command(args.java, build, variant, args.runtime, exports, folder / "audit.json") + except AssertionError: + log = (folder / "audit.log").read_text() + if "java.lang.AssertionError: ORDER_PROBE:" not in log: + raise AssertionError("Mutation failed for an unrelated reason: " + mutation) + results[mutation] = "REJECTED" + else: + raise AssertionError("Woven mutation was accepted: " + mutation) + if before != fingerprint(args.exports) or production_before != fingerprint(args.runtime / "mods"): + raise AssertionError("Negative controls changed actual exports or production jars") + receipt = dict(status="PASS", scope=probe.SCOPE, qualificationAllowed=False, controls=results, + originalExportsUnchanged=True, allRuntimeJarsUnchanged=True, + buildReceiptSha256=digest(args.build_receipt), sourceSha256=digest(Path(__file__)), + fixtureUtilitySha256=digest(ROOT / "test/WovenNegativeControl.java")) + write_json(args.output / "negative-controls.json", receipt) + print(json.dumps(receipt, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/test-rig/platform-controls/NativeLeashKnotPlatformControl.java b/test-rig/platform-controls/NativeLeashKnotPlatformControl.java index 4c467a09..6843e3d4 100644 --- a/test-rig/platform-controls/NativeLeashKnotPlatformControl.java +++ b/test-rig/platform-controls/NativeLeashKnotPlatformControl.java @@ -1,76 +1,116 @@ import com.google.gson.GsonBuilder; -import java.io.InputStream; -import java.lang.reflect.Field; +import com.mojang.serialization.Codec; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.MessageDigest; import java.util.LinkedHashMap; import java.util.Map; +import java.util.TreeSet; import java.util.UUID; -import java.util.function.Supplier; import net.minecraft.SharedConstants; import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; import net.minecraft.server.Bootstrap; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.util.RandomSource; -import net.minecraft.util.profiling.InactiveProfiler; -import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraft.util.datafix.fixes.BlockStateData; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LightningBolt; import net.minecraft.world.entity.decoration.LeashFenceKnotEntity; -import net.minecraft.world.level.Level; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; -/** No-Power, no-server native storage-eligibility control. */ +/** No-Power, no-server, unticked native saveability and writer-eligibility control. */ public final class NativeLeashKnotPlatformControl { - private static void field(ServerLevel world, String name, Object value) throws Exception { - Field field = Level.class.getDeclaredField(name); field.setAccessible(true); field.set(world, value); - } - private static String hash(Class type) throws Exception { - try (InputStream input = type.getResourceAsStream("/" + type.getName().replace('.', '/') + ".class")) { - if (input == null) throw new AssertionError("No native class bytes"); - StringBuilder result = new StringBuilder(); - for (byte value : MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())) result.append(String.format("%02X", value & 255)); - return result.toString(); - } - } + private static final UUID ID = UUID.fromString("ac980b6e-a321-4275-8d6e-47b2e2607794"); + public static void main(String[] args) throws Exception { - SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); + NativePlatformSupport.requireIndependentClasspath(); + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); zone.moddev.mc.advantageworks.unit.ForgeUnitRegistries.registerVanillaFluidTypes(); - try { - Class.forName("zone.moddev.mc.basepower.migration.LegacyEntityDataFixes"); - throw new AssertionError("Power must be absent from the native control"); - } catch (ClassNotFoundException expected) { System.out.println("POWER_LEGACY_CLASS_ABSENT true"); } - Class unsafe = Class.forName("sun.misc.Unsafe"); - Field instance = unsafe.getDeclaredField("theUnsafe"); instance.setAccessible(true); - ServerLevel world = (ServerLevel) unsafe.getMethod("allocateInstance", Class.class).invoke(instance.get(null), ServerLevel.class); - field(world, "dimension", Level.NETHER); field(world, "random", RandomSource.create(0)); - field(world, "profiler", (Supplier) () -> InactiveProfiler.INSTANCE); - LeashFenceKnotEntity knot = new LeashFenceKnotEntity(world, new BlockPos(154, 100, 8)); - UUID identity = knot.getUUID(); - CompoundTag before = knot.saveWithoutId(new CompoundTag()).copy(); - boolean accepted = knot.saveAsPassenger(new CompoundTag()); - CompoundTag observed = knot.saveWithoutId(new CompoundTag()); + var world = NativePlatformSupport.context(false); + BlockPos attachment = new BlockPos(154, 100, 8); + LeashFenceKnotEntity knot = new LeashFenceKnotEntity(world, attachment); + knot.setUUID(ID); + CompoundTag before = NativePlatformSupport.observe(knot).copy(); + var rootOutput = NativePlatformSupport.output(knot); + boolean rootAccepted = knot.save(rootOutput); + var passengerOutput = NativePlatformSupport.output(knot); + boolean passengerAccepted = knot.saveAsPassenger(passengerOutput); + CompoundTag observed = NativePlatformSupport.observe(knot); + boolean selected = knot.shouldBeSaved(); + boolean encodable = knot.getEncodeId() != null; if (knot.getClass() != LeashFenceKnotEntity.class || knot.getType() != EntityType.LEASH_KNOT - || EntityType.LEASH_KNOT.canSerialize() || accepted || !identity.equals(knot.getUUID()) - || !before.equals(observed) || knot.tickCount != 0 || knot.isRemoved()) throw new AssertionError("Native noSave contract differs"); - Map proof = new LinkedHashMap<>(); - proof.put("format", 1); proof.put("policy", "native1211-leash-knot-nosave-v1"); - proof.put("minecraftVersion", SharedConstants.getCurrentVersion().getName()); - proof.put("dataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + || knot.getType().canSerialize() || rootAccepted || passengerAccepted || !selected || encodable + || !rootOutput.buildResult().isEmpty() || !passengerOutput.buildResult().isEmpty() + || !ID.equals(before.read("UUID", UUIDUtil.CODEC).orElseThrow()) || !ID.equals(knot.getUUID()) + || !before.equals(observed) || knot.tickCount != 0 || knot.isRemoved() || knot.isPassenger() + || !knot.getPassengers().isEmpty() || !attachment.equals(knot.getPos()) + || before.contains("id") || before.contains("Passengers") || before.contains("block_pos") + || before.contains("TileX") || before.contains("TileY") || before.contains("TileZ")) { + throw new AssertionError("Native noSave contract differs"); + } + var position = observed.getList("Pos").orElseThrow(); + if (position.size() != 3 || position.getDouble(0).orElseThrow() != 154.5 + || position.getDouble(1).orElseThrow() != 100.375 || position.getDouble(2).orElseThrow() != 8.5) { + throw new AssertionError("Native observational position does not identify the actual attachment"); + } + + // Corroborate both sides of eligibility and reject any reporter that swallows codec errors. + ItemEntity persistent = new ItemEntity(world, 8.5, 80, 8.5, new ItemStack(Items.DIRT, 7)); + var persistentOutput = NativePlatformSupport.output(persistent); + if (!persistent.getType().canSerialize() || !persistent.save(persistentOutput) + || !"minecraft:item".equals(persistentOutput.buildResult().getString("id").orElseThrow())) { + throw new AssertionError("Persistent native writer control failed"); + } + LightningBolt otherNoSave = new LightningBolt(EntityType.LIGHTNING_BOLT, world); + if (otherNoSave.getType().canSerialize() || otherNoSave.save(NativePlatformSupport.output(otherNoSave))) { + throw new AssertionError("Unexpected native lightning writer eligibility"); + } + try { + NativePlatformSupport.output(knot).store("invalid", Codec.intRange(0, 1), 7); + throw new AssertionError("Codec failure was discarded"); + } catch (IllegalStateException expected) { + if (!expected.getMessage().contains("Native control codec failure")) throw expected; + } + if (!before.equals(NativePlatformSupport.observe(knot))) throw new AssertionError("Control mutated native helper state"); + + Map proof = new LinkedHashMap<>(); + proof.put("format", 1); + proof.put("policy", "native12111-leash-knot-nosave-v1"); + proof.put("minecraftVersion", SharedConstants.getCurrentVersion().name()); + proof.put("dataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); proof.put("forgeVersion", net.minecraftforge.versions.forge.ForgeVersion.getVersion()); - proof.put("nativeClass", knot.getClass().getName()); proof.put("registryId", EntityType.getKey(knot.getType()).toString()); - proof.put("canSerialize", false); proof.put("rootWriterAccepted", false); - proof.put("observationalUuidPreserved", true); proof.put("observationalStateUnchanged", true); - proof.put("noPower", true); proof.put("noServer", true); proof.put("noTicks", true); proof.put("nativeMethodsUnmodified", true); - Map classes = new LinkedHashMap<>(); - for (Class type : new Class[] {Entity.class, EntityType.class, LeashFenceKnotEntity.class}) classes.put(type.getName(), hash(type)); - proof.put("nativeClassSha256", classes); - Path root = Paths.get(args[0]); Files.createDirectory(root); + proof.put("nativeClass", knot.getClass().getName()); + proof.put("registryId", EntityType.getKey(knot.getType()).toString()); + proof.put("canSerialize", knot.getType().canSerialize()); + proof.put("storageSelectorAccepts", selected); + proof.put("encodedIdPresent", encodable); + proof.put("rootWriterAccepted", rootAccepted); + proof.put("passengerWriterAccepted", passengerAccepted); + proof.put("refusedWritersLeaveNoPayload", rootOutput.buildResult().isEmpty() && passengerOutput.buildResult().isEmpty()); + proof.put("observationalUuidPreserved", ID.equals(observed.read("UUID", UUIDUtil.CODEC).orElseThrow())); + proof.put("observationalStateUnchanged", before.equals(observed)); + proof.put("observationalTagKeys", new TreeSet<>(observed.keySet())); + proof.put("noPower", true); + proof.put("powerAccessTransformerAbsent", true); + proof.put("noHarness", true); + proof.put("noServer", world.getServer() == null); + proof.put("noTicks", knot.tickCount == 0); + proof.put("nativeMethodsUnmodified", true); + proof.put("nativeClassSha256", NativePlatformSupport.classHashes(Entity.class, EntityType.class, LeashFenceKnotEntity.class, BlockStateData.class)); + NativePlatformSupport.sourceEvidence(proof, Entity.class, EntityType.class, LeashFenceKnotEntity.class, BlockStateData.class); + Path root = Path.of(args[0]); + Files.createDirectory(root); + NbtIo.writeCompressed(before, root.resolve("helper-before.dat")); + NbtIo.writeCompressed(observed, root.resolve("helper-after.dat")); + NbtIo.writeCompressed(persistentOutput.buildResult(), root.resolve("persistent-control.dat")); String json = new GsonBuilder().setPrettyPrinting().create().toJson(proof) + "\n"; Files.writeString(root.resolve("contract.json"), json); System.out.println("LEASH_KNOT_CONTROL " + json.replace('\n', ' ')); + System.out.println("LEASH_KNOT_CONTRACT_SHA256 " + NativePlatformSupport.sha(json.getBytes(java.nio.charset.StandardCharsets.UTF_8))); System.out.println("LEASH_KNOT_CONTROL_DONE no_server=true no_ticks=true native_entity_methods_unmodified=true"); } } diff --git a/test-rig/platform-controls/NativePlatformSupport.java b/test-rig/platform-controls/NativePlatformSupport.java new file mode 100644 index 00000000..59a1949d --- /dev/null +++ b/test-rig/platform-controls/NativePlatformSupport.java @@ -0,0 +1,142 @@ +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipFile; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.RegistryDataLoader; +import net.minecraft.server.RegistryLayer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.tags.TagLoader; +import net.minecraft.util.ProblemReporter; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.world.level.storage.TagValueOutput; + +/** Standalone controls deliberately share no harness persistence or world fixtures. */ +final class NativePlatformSupport { + private NativePlatformSupport() {} + + static void requireIndependentClasspath() throws Exception { + for (String name : List.of("zone.moddev.mc.basepower.migration.LegacyEntityDataFixes", + "zone.moddev.mc.advantageworks.NativeActorIo")) { + try { + Class.forName(name); + throw new AssertionError("Advantage/harness must be absent from the native control: " + name); + } catch (ClassNotFoundException expected) { } + } + Field nativeMap = net.minecraft.util.datafix.fixes.BlockStateData.class.getDeclaredField("MAP"); + if (nativeMap.getModifiers() != (Modifier.PRIVATE | Modifier.STATIC | Modifier.FINAL)) { + throw new AssertionError("Power's BlockStateData.MAP access transformer must be absent from the native control"); + } + System.out.println("POWER_AND_HARNESS_CLASSES_ABSENT true"); + System.out.println("POWER_ACCESS_TRANSFORMER_ABSENT true"); + } + + static ServerLevel context(boolean worldgen) throws Exception { + Class unsafe = Class.forName("sun.misc.Unsafe"); + Field instance = unsafe.getDeclaredField("theUnsafe"); + instance.setAccessible(true); + ServerLevel world = (ServerLevel) unsafe.getMethod("allocateInstance", Class.class) + .invoke(instance.get(null), ServerLevel.class); + field(world, "dimension", Level.NETHER); + field(world, "random", RandomSource.create(0)); + RegistryAccess registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + if (worldgen) { + var base = RegistryLayer.createRegistryAccess(); + try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, + List.of(ServerPacksSource.createVanillaPackSource()))) { + var tags = TagLoader.loadTagsForExistingRegistries(resources, base.getLayer(RegistryLayer.STATIC)); + var lookups = TagLoader.buildUpdatedLookups(base.getAccessForLoading(RegistryLayer.WORLDGEN), tags); + var loaded = RegistryDataLoader.load(resources, lookups, + RegistryDataLoader.WORLDGEN_REGISTRIES); + registries = base.replaceFrom(RegistryLayer.WORLDGEN, loaded).compositeAccess(); + } + } + field(world, "registryAccess", registries); + return world; + } + + private static void field(ServerLevel world, String name, Object value) throws Exception { + Field field = Level.class.getDeclaredField(name); + field.setAccessible(true); + field.set(world, value); + } + + static ProblemReporter reporter(String path) { + return new ProblemReporter() { + @Override public ProblemReporter forChild(PathElement child) { return reporter(path + child.get()); } + @Override public void report(Problem problem) { + throw new IllegalStateException("Native control codec failure at " + path + ": " + problem.description()); + } + }; + } + + static TagValueOutput output(Entity entity) { + return TagValueOutput.createWithContext(reporter(entity.getClass().getName()), entity.registryAccess()); + } + + static CompoundTag observe(Entity entity) { + TagValueOutput output = output(entity); + entity.saveWithoutId(output); + return output.buildResult(); + } + + static void load(Entity entity, CompoundTag nbt) { + entity.load(TagValueInput.create(reporter(entity.getClass().getName()), entity.registryAccess(), nbt)); + } + + static String sha(byte[] bytes) throws Exception { + return HexFormat.of().withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } + + static String classHash(Class type) throws Exception { + try (InputStream input = type.getResourceAsStream("/" + type.getName().replace('.', '/') + ".class")) { + if (input == null) throw new AssertionError("No native class bytes: " + type.getName()); + return sha(input.readAllBytes()); + } + } + + static Map classHashes(Class... types) throws Exception { + Map result = new LinkedHashMap<>(); + for (Class type : types) result.put(type.getName(), classHash(type)); + return result; + } + + static void sourceEvidence(Map proof, Class... types) throws Exception { + Path engine = Path.of(Entity.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + if (!Files.isRegularFile(engine)) throw new AssertionError("Native engine is not a jar: " + engine); + String name = engine.getFileName().toString(); + Path sources = engine.resolveSibling(name.substring(0, name.length() - 4) + "-sources.jar"); + proof.put("engineJarSha256", sha(Files.readAllBytes(engine))); + proof.put("engineSourcesSha256", sha(Files.readAllBytes(sources))); + Map hashes = new LinkedHashMap<>(); + try (ZipFile zip = new ZipFile(sources.toFile())) { + for (Class type : types) { + String path = type.getName().replace('.', '/') + ".java"; + var entry = zip.getEntry(path); + if (entry == null) throw new AssertionError("Missing pinned native source: " + path); + try (InputStream input = zip.getInputStream(entry)) { hashes.put(path, sha(input.readAllBytes())); } + } + } + proof.put("nativeSourceSha256", hashes); + Map controls = new LinkedHashMap<>(); + for (String source : List.of("NativePlatformSupport.java", "NativeLeashKnotPlatformControl.java")) { + controls.put(source, sha(Files.readAllBytes(Path.of("platform-controls", source)))); + } + proof.put("controlSourceSha256", controls); + } +} diff --git a/test-rig/platform-controls/NativePlayerFormatPlatformControl.java b/test-rig/platform-controls/NativePlayerFormatPlatformControl.java new file mode 100644 index 00000000..cd84e108 --- /dev/null +++ b/test-rig/platform-controls/NativePlayerFormatPlatformControl.java @@ -0,0 +1,87 @@ +import com.google.gson.Gson; +import com.mojang.serialization.Dynamic; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.minecraft.SharedConstants; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtOps; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.util.datafix.fixes.References; + +/** Real native DFU and respawn codec against retained player files, without mods or a server. */ +public final class NativePlayerFormatPlatformControl { + public static void main(String[] args) throws Exception { + NativePlatformSupport.requireIndependentClasspath(); + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + int targetVersion = SharedConstants.getCurrentVersion().dataVersion().version(); + if (targetVersion != 4671 || args.length < 4 || args.length % 3 != 1) { + throw new AssertionError("Expected an output directory and label/source/saved triples for native4671"); + } + Path output = Path.of(args[0]); + Files.createDirectory(output); + Map proof = new LinkedHashMap<>(); + proof.put("targetDataVersion", targetVersion); + proof.put("noPower", true); + proof.put("noHarness", true); + proof.put("noServerOrTicks", true); + NativePlatformSupport.sourceEvidence(proof, + net.minecraft.util.datafix.fixes.PlayerEquipmentFix.class, + net.minecraft.util.datafix.fixes.InlineBlockPosFormatFix.class, + net.minecraft.util.datafix.fixes.PlayerRespawnDataFix.class, + net.minecraft.world.entity.LivingEntity.class, + net.minecraft.server.level.ServerPlayer.class); + proof.put("nativeClassSha256", NativePlatformSupport.classHashes( + net.minecraft.util.datafix.fixes.PlayerEquipmentFix.class, + net.minecraft.util.datafix.fixes.InlineBlockPosFormatFix.class, + net.minecraft.util.datafix.fixes.PlayerRespawnDataFix.class)); + proof.put("controlSourceSha256", NativePlatformSupport.sha(Files.readAllBytes( + Path.of("platform-controls/NativePlayerFormatPlatformControl.java")))); + Map observations = new LinkedHashMap<>(); + for (int index = 1; index < args.length; index += 3) { + String label = args[index]; + Path sourcePath = Path.of(args[index + 1]); + Path savedPath = Path.of(args[index + 2]); + CompoundTag source = NbtIo.readCompressed(sourcePath, NbtAccounter.unlimitedHeap()); + CompoundTag saved = NbtIo.readCompressed(savedPath, NbtAccounter.unlimitedHeap()); + int sourceVersion = source.getInt("DataVersion").orElseThrow(); + if (sourceVersion >= targetVersion || saved.getInt("DataVersion").orElseThrow() != targetVersion) { + throw new AssertionError("Wrong source or actual saved player version: " + label); + } + CompoundTag fixed = (CompoundTag) DataFixers.getDataFixer().update(References.PLAYER, + new Dynamic<>(NbtOps.INSTANCE, source.copy()), sourceVersion, targetVersion).getValue(); + for (String field : List.of("Inventory", "EnderItems", "equipment", "respawn")) { + if (!Objects.equals(fixed.get(field), saved.get(field))) { + throw new AssertionError(label + ": independent native DFU differs from actual saved " + field + + " expected=" + fixed.get(field) + " saved=" + saved.get(field)); + } + } + if (!Arrays.equals(fixed.getIntArray("UUID").orElseThrow(), saved.getIntArray("UUID").orElseThrow())) { + throw new AssertionError("Player UUID changed: " + label); + } + if (fixed.contains("respawn")) { + CompoundTag respawn = fixed.getCompound("respawn").orElseThrow(); + var decoded = ServerPlayer.RespawnConfig.CODEC.parse(NbtOps.INSTANCE, respawn).getOrThrow(); + var encoded = ServerPlayer.RespawnConfig.CODEC.encodeStart(NbtOps.INSTANCE, decoded).getOrThrow(); + if (!respawn.equals(encoded)) throw new AssertionError("Native respawn codec changed fields: " + label); + } + NbtIo.writeCompressed(fixed, output.resolve(label + "-dfu.dat")); + observations.put(label, Map.of("source", sourcePath.toString(), "sourceSha256", NativePlatformSupport.sha(Files.readAllBytes(sourcePath)), + "saved", savedPath.toString(), "savedSha256", NativePlatformSupport.sha(Files.readAllBytes(savedPath)), + "sourceDataVersion", sourceVersion, "fullInventoryEquipmentRespawnExactMatch", true, "uuidPreserved", true)); + System.out.println("NATIVE_PLAYER_FORMAT_MATCH " + label); + } + proof.put("observations", observations); + Files.writeString(output.resolve("receipt.json"), new Gson().toJson(proof) + "\n"); + System.out.println("NATIVE_PLAYER_FORMAT_DONE no_power=true no_server=true no_ticks=true"); + } +} diff --git a/test-rig/platform-controls/NativeWolfPlatformControl.java b/test-rig/platform-controls/NativeWolfPlatformControl.java index 43f9c3a9..d7dcb9a1 100644 --- a/test-rig/platform-controls/NativeWolfPlatformControl.java +++ b/test-rig/platform-controls/NativeWolfPlatformControl.java @@ -5,53 +5,35 @@ import java.nio.file.Paths; import java.util.LinkedHashMap; import java.util.Map; -import java.util.List; import java.util.UUID; -import java.util.function.Supplier; import net.minecraft.SharedConstants; import net.minecraft.nbt.NbtAccounter; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtIo; import net.minecraft.server.Bootstrap; -import net.minecraft.server.RegistryLayer; import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.packs.PackType; -import net.minecraft.server.packs.repository.ServerPacksSource; -import net.minecraft.server.packs.resources.MultiPackResourceManager; -import net.minecraft.resources.RegistryDataLoader; -import net.minecraft.util.RandomSource; -import net.minecraft.util.profiling.InactiveProfiler; -import net.minecraft.util.profiling.ProfilerFiller; import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.animal.Wolf; -import net.minecraft.world.level.Level; +import net.minecraft.world.entity.EntityReference; +import net.minecraft.world.entity.animal.wolf.Wolf; import net.minecraftforge.registries.ForgeRegistries; import zone.moddev.mc.advantageworks.unit.ForgeUnitRegistries; -/** Independent native controls: no Advantage classes, server or entity ticks. */ +/** Independent native controls: no Advantage classes, running server or entity ticks. */ public final class NativeWolfPlatformControl { private static final UUID OWNER = UUID.fromString("a614e658-6270-33c3-9297-7ce80908964f"); private static final UUID ID = UUID.fromString("a6ea8abd-6ab3-4e66-8ef5-d713aabbccdd"); - private static void field(ServerLevel world, String name, Object value) throws Exception { - Field field = Level.class.getDeclaredField(name); field.setAccessible(true); field.set(world, value); - } - private static ServerLevel context() throws Exception { + ServerLevel world = NativePlatformSupport.context(true); + // Native navigation now consults debug subscribers even before any entity ticks. Class unsafe = Class.forName("sun.misc.Unsafe"); Field instance = unsafe.getDeclaredField("theUnsafe"); instance.setAccessible(true); - ServerLevel world = (ServerLevel) unsafe.getMethod("allocateInstance", Class.class) - .invoke(instance.get(null), ServerLevel.class); - field(world, "dimension", Level.NETHER); - field(world, "random", RandomSource.create(0)); - field(world, "profiler", (Supplier) () -> InactiveProfiler.INSTANCE); - var base = RegistryLayer.createRegistryAccess(); - try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, - List.of(ServerPacksSource.createVanillaPackSource()))) { - var worldgen = RegistryDataLoader.load(resources, base.compositeAccess(), - RegistryDataLoader.WORLDGEN_REGISTRIES); - field(world, "registryAccess", base.replaceFrom(RegistryLayer.WORLDGEN, worldgen).compositeAccess()); - } + var server = (net.minecraft.server.MinecraftServer) unsafe.getMethod("allocateInstance", Class.class) + .invoke(instance.get(null), net.minecraft.server.dedicated.DedicatedServer.class); + Field debug = net.minecraft.server.MinecraftServer.class.getDeclaredField("debugSubscribers"); + debug.setAccessible(true); + debug.set(server, new net.minecraft.util.debug.ServerDebugSubscribers(server)); + Field host = ServerLevel.class.getDeclaredField("server"); host.setAccessible(true); host.set(world, server); return world; } @@ -63,32 +45,42 @@ private static void control(Path root, ServerLevel world, boolean tame, float he String label = (tame ? "tame" : "wild") + "-health-" + (int) health; Wolf first = wolf(world); first.setTame(tame, true); - if (tame) first.setOwnerUUID(OWNER); + if (tame) first.setOwnerReference(EntityReference.of(OWNER)); first.setHealth(health); - CompoundTag before = first.saveWithoutId(new CompoundTag()); - if (before.getFloat("Health") != health) throw new AssertionError("Incorrect source control health"); + CompoundTag before = NativePlatformSupport.observe(first); + if (before.getFloat("Health").orElseThrow() != health) throw new AssertionError("Incorrect source control health"); Path input = root.resolve(label + "-before.dat"); NbtIo.writeCompressed(before, input); - Wolf second = wolf(world); second.load(NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap())); - CompoundTag after = second.saveWithoutId(new CompoundTag()); + Wolf second = wolf(world); NativePlatformSupport.load(second, NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap())); + CompoundTag after = NativePlatformSupport.observe(second); NbtIo.writeCompressed(after, root.resolve(label + "-after.dat")); - Wolf third = wolf(world); third.load(after.copy()); - CompoundTag restarted = third.saveWithoutId(new CompoundTag()); + Wolf third = wolf(world); NativePlatformSupport.load(third, after.copy()); + CompoundTag restarted = NativePlatformSupport.observe(third); NbtIo.writeCompressed(restarted, root.resolve(label + "-restart.dat")); + if (second.getHealth() != health || third.getHealth() != health || second.isTame() != tame || third.isTame() != tame + || second.getMaxHealth() != (tame ? 40 : 8) || third.getMaxHealth() != (tame ? 40 : 8) + || first.tickCount != 0 || second.tickCount != 0 || third.tickCount != 0 || world.getServer().isRunning() + || !before.equals(after) || !after.equals(restarted)) { + throw new AssertionError("Native current-wolf round trip drifted"); + } if (!ID.equals(second.getUUID()) || !ID.equals(third.getUUID())) throw new AssertionError("Control UUID changed"); - if (tame && (!OWNER.equals(second.getOwnerUUID()) || !OWNER.equals(third.getOwnerUUID()))) { + if (tame && (second.getOwnerReference() == null || third.getOwnerReference() == null + || !OWNER.equals(second.getOwnerReference().getUUID()) || !OWNER.equals(third.getOwnerReference().getUUID()))) { throw new AssertionError("Control owner changed"); } + if (!tame && (second.getOwnerReference() != null || third.getOwnerReference() != null)) + throw new AssertionError("Native wild-wolf control acquired an owner"); Map row = new LinkedHashMap<>(); - row.put("label", label); row.put("currentWorldDataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + row.put("label", label); row.put("currentWorldDataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); row.put("legacyDfuInvoked", false); row.put("directNativeEntityLoadSave", true); - row.put("beforeHealth", before.getFloat("Health")); row.put("afterHealth", after.getFloat("Health")); - row.put("restartHealth", restarted.getFloat("Health")); - row.put("uuidPreserved", true); row.put("ownerPreserved", !tame || OWNER.equals(second.getOwnerUUID())); + row.put("beforeHealth", before.getFloat("Health").orElseThrow()); row.put("afterHealth", after.getFloat("Health").orElseThrow()); + row.put("restartHealth", restarted.getFloat("Health").orElseThrow()); + row.put("uuidPreserved", true); row.put("ownerPreserved", !tame || OWNER.equals(second.getOwnerReference().getUUID())); + row.put("serverContext", "unstarted-native-debug-subscriber-fixture"); row.put("noServerStarted", true); System.out.println("WOLF_CONTROL " + new Gson().toJson(row)); } private static void legacyControl(Path root, ServerLevel world, int version) throws Exception { - CompoundTag before = net.minecraft.nbt.TagParser.parseTag("{id:\"" + (version == 512 ? "Wolf" : "minecraft:wolf") + CompoundTag before = net.minecraft.nbt.TagParser.parseCompoundFully("{id:\"" + (version == 512 ? "Wolf" : "minecraft:wolf") + "\",Health:8f,Attributes:[{Name:\"generic.maxHealth\",Base:20d}],Pos:[0d,100d,0d]," + "Motion:[0d,0d,0d],Rotation:[0f,0f]}"); before.putLong("UUIDMost", ID.getMostSignificantBits()); @@ -97,18 +89,19 @@ private static void legacyControl(Path root, ServerLevel world, int version) thr var fixed = net.minecraft.util.datafix.DataFixers.getDataFixer().update( net.minecraft.util.datafix.fixes.References.ENTITY, new com.mojang.serialization.Dynamic<>(net.minecraft.nbt.NbtOps.INSTANCE, before.copy()), - version, SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + version, SharedConstants.getCurrentVersion().dataVersion().version()); CompoundTag nativeTag = (CompoundTag) fixed.getValue(); Wolf loaded = wolf(world); - loaded.load(nativeTag); - CompoundTag saved = loaded.saveWithoutId(new CompoundTag()); + NativePlatformSupport.load(loaded, nativeTag); + CompoundTag saved = NativePlatformSupport.observe(loaded); NbtIo.writeCompressed(before, root.resolve("legacy-" + version + "-source.dat")); NbtIo.writeCompressed(nativeTag, root.resolve("legacy-" + version + "-dfu.dat")); NbtIo.writeCompressed(saved, root.resolve("legacy-" + version + "-saved.dat")); if (loaded.getHealth() != 16 || loaded.getMaxHealth() != 40 || !ID.equals(loaded.getUUID()) - || !OWNER.equals(loaded.getOwnerUUID())) throw new AssertionError("Unexpected native legacy-wolf conversion"); + || loaded.getOwnerReference() == null || !OWNER.equals(loaded.getOwnerReference().getUUID())) + throw new AssertionError("Unexpected native legacy-wolf conversion"); Map row = new LinkedHashMap<>(); - row.put("sourceDataVersion", version); row.put("targetDataVersion", 3955); + row.put("sourceDataVersion", version); row.put("targetDataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); row.put("sourceHealth", 8); row.put("savedHealth", loaded.getHealth()); row.put("sourceMaxHealth", 20); row.put("savedMaxHealth", loaded.getMaxHealth()); row.put("uuidPreserved", true); row.put("ownerPreserved", true); row.put("noPower", true); @@ -118,9 +111,10 @@ private static void legacyControl(Path root, ServerLevel world, int version) thr } public static void main(String[] args) throws Exception { + NativePlatformSupport.requireIndependentClasspath(); SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); ForgeUnitRegistries.registerVanillaFluidTypes(); - ForgeUnitRegistries.registerForgeEntries("ATTRIBUTES", ForgeRegistries.Keys.ATTRIBUTES, ForgeRegistries.ATTRIBUTES); + ForgeUnitRegistries.registerForgeEntries("ATTRIBUTES", net.minecraft.core.registries.Registries.ATTRIBUTE, net.minecraftforge.registries.ForgeRegistries.ATTRIBUTES); try { Class.forName("zone.moddev.mc.basepower.migration.LegacyEntityDataFixes"); throw new AssertionError("Advantage is present on the native control classpath"); @@ -130,6 +124,6 @@ public static void main(String[] args) throws Exception { ServerLevel world = context(); control(root, world, true, 8); control(root, world, true, 20); control(root, world, false, 8); legacyControl(root, world, 512); legacyControl(root, world, 1343); - System.out.println("WOLF_CONTROL_DONE no_server=true no_ticks=true native_entity_methods_unmodified=true"); + System.out.println("WOLF_CONTROL_DONE no_server_started=true no_ticks=true native_entity_methods_unmodified=true"); } } diff --git a/test-rig/platform-controls/NativeWorldgenDfuPlatformControl.java b/test-rig/platform-controls/NativeWorldgenDfuPlatformControl.java new file mode 100644 index 00000000..e8581131 --- /dev/null +++ b/test-rig/platform-controls/NativeWorldgenDfuPlatformControl.java @@ -0,0 +1,108 @@ +import com.google.gson.Gson; +import com.mojang.serialization.Dynamic; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.SharedConstants; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.StringTag; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryOps; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.util.datafix.fixes.References; +import net.minecraft.world.level.levelgen.WorldGenSettings; +import net.minecraftforge.registries.ForgeRegistries; +import zone.moddev.mc.advantageworks.unit.ForgeUnitRegistries; + +/** Read-only real Forge61 DFU/codec control for the two retained vanilla-flat 1.13 worlds. */ +public final class NativeWorldgenDfuPlatformControl { + private static Dynamic fixed(CompoundTag data, int version) { + var fixer = DataFixers.getDataFixer(); + Dynamic dynamic = DataFixTypes.LEVEL.updateToCurrentVersion(fixer, new Dynamic<>(NbtOps.INSTANCE, data.copy()), version); + dynamic = dynamic.update("Player", value -> DataFixTypes.PLAYER.updateToCurrentVersion(fixer, value, version)); + return dynamic.update("WorldGenSettings", value -> DataFixTypes.WORLD_GEN_SETTINGS.updateToCurrentVersion(fixer, value, version)); + } + + public static void main(String[] args) throws Exception { + NativePlatformSupport.requireIndependentClasspath(); + SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); + ForgeUnitRegistries.registerVanillaFluidTypes(); + if (SharedConstants.getCurrentVersion().dataVersion().version() != 4671 || args.length != 3) { + throw new AssertionError("Expected native4671, new output directory and two source level.dat paths"); + } + Path output = Path.of(args[0]); Files.createDirectory(output); + Identifier wastes = Identifier.parse("minecraft:nether_wastes"); + boolean forgeContainsBefore = ForgeRegistries.BIOMES.containsKey(wastes); + RegistryAccess registries = NativePlatformSupport.context(true).registryAccess(); + boolean datapackContains = registries.lookupOrThrow(Registries.BIOME).containsKey(wastes); + boolean forgeContainsAfter = ForgeRegistries.BIOMES.containsKey(wastes); + if (!datapackContains || forgeContainsBefore || forgeContainsAfter) { + throw new AssertionError("Unexpected native static-Forge/data-pack biome registry boundary"); + } + Tag renamed = DataFixers.getDataFixer().update(References.BIOME, + new Dynamic<>(NbtOps.INSTANCE, StringTag.valueOf("minecraft:nether")), 1631, 4671).getValue(); + if (!renamed.equals(StringTag.valueOf("minecraft:nether_wastes"))) throw new AssertionError("Native Nether biome rename changed"); + Map observations = new LinkedHashMap<>(); + for (int index = 1; index < args.length; index++) { + Path path = Path.of(args[index]); + String sourceSha = NativePlatformSupport.sha(Files.readAllBytes(path)); + CompoundTag root = NbtIo.readCompressed(path, NbtAccounter.unlimitedHeap()); + CompoundTag data = root.getCompound("Data").orElseThrow(); + int version = data.getInt("DataVersion").orElseThrow(); + String generator = data.getString("generatorName").orElseThrow(); + if (version != 1631 || !generator.equals("flat")) throw new AssertionError("Not the reviewed 1.13 vanilla-flat source"); + var dynamic = fixed(data, version); + Tag settings = dynamic.get("WorldGenSettings").get().getOrThrow().getValue(); + var ops = RegistryOps.create(NbtOps.INSTANCE, registries); + WorldGenSettings decoded = WorldGenSettings.CODEC.parse(ops, settings).getOrThrow(); + if (decoded == null) throw new AssertionError("Missing native decoded world settings"); + CompoundTag worldgen = (CompoundTag) settings; + CompoundTag dimensions = worldgen.getCompound("dimensions").orElseThrow(); + if (!dimensions.keySet().equals(java.util.Set.of("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"))) { + throw new AssertionError("Unexpected source dimension set"); + } + String nativeGenerator = dimensions.getCompound("minecraft:overworld").orElseThrow() + .getCompound("generator").orElseThrow().getString("type").orElseThrow(); + if (!nativeGenerator.equals("minecraft:flat")) throw new AssertionError("Vanilla flat generator was lost"); + CompoundTag invalid = worldgen.copy(); + invalid.getCompound("dimensions").orElseThrow().getCompound("minecraft:overworld").orElseThrow() + .getCompound("generator").orElseThrow().putString("type", "fixture:unsupported_generator"); + boolean invalidRejected = false; + try { WorldGenSettings.CODEC.parse(ops, invalid).getOrThrow(); } + catch (IllegalStateException expected) { invalidRejected = true; } + if (!invalidRejected) throw new AssertionError("Invalid generator negative control was accepted"); + CompoundTag evidence = new CompoundTag(); evidence.put("Data", dynamic.getValue()); + // Keep the untouched old Forge registry snapshot beside the independently fixed native Data. + evidence.put("FML", root.getCompound("FML").orElseThrow().copy()); + Path fixedPath = output.resolve("source-" + index + "-fixed-level.dat"); NbtIo.writeCompressed(evidence, fixedPath); + if (!sourceSha.equals(NativePlatformSupport.sha(Files.readAllBytes(path)))) throw new AssertionError("Protected source level.dat changed"); + observations.put(path.toString(), Map.of("sourceSha256", sourceSha, "sourceDataVersion", version, + "sourceGenerator", generator, "nativeGenerator", nativeGenerator, "nativeWorldGenCodec", "PASS", + "invalidGeneratorRejected", true, "fixedEvidenceSha256", NativePlatformSupport.sha(Files.readAllBytes(fixedPath)), + "sourceUnchanged", true)); + } + Map receipt = new LinkedHashMap<>(); + receipt.put("status", "PASS"); receipt.put("scope", "READ_ONLY_NATIVE_DFU_AND_WORLDGEN_CODEC_DIAGNOSIS"); + receipt.put("noPower", true); receipt.put("noHarness", true); receipt.put("noServerStartedOrTicks", true); + receipt.put("nativeDfuBiomeRename", "minecraft:nether -> minecraft:nether_wastes"); + receipt.put("forgeBiomeRegistryContainsBeforeDatapacks", forgeContainsBefore); + receipt.put("forgeBiomeRegistryContainsAfterDatapacks", forgeContainsAfter); + receipt.put("nativeDatapackBiomeRegistryContainsTarget", datapackContains); + receipt.put("sourceWorlds", observations); + NativePlatformSupport.sourceEvidence(receipt, net.minecraft.util.datafix.DataFixers.class, + net.minecraft.world.level.storage.LevelStorageSource.class, ForgeRegistries.class, WorldGenSettings.class); + receipt.put("controlSourceSha256", NativePlatformSupport.sha(Files.readAllBytes(Path.of("platform-controls/NativeWorldgenDfuPlatformControl.java")))); + Files.writeString(output.resolve("receipt.json"), new Gson().toJson(receipt) + "\n"); + System.out.println("TWO_REAL_SOURCE_LEVELS_NATIVE_DFU_AND_CODEC_PASS static_forge_biome_missing=true native_datapack_biome_present=true"); + } +} diff --git a/test-rig/profiles/native12111/README.md b/test-rig/profiles/native12111/README.md new file mode 100644 index 00000000..a406a884 --- /dev/null +++ b/test-rig/profiles/native12111/README.md @@ -0,0 +1,98 @@ +# Forge 61 Test Runs + +These profiles are for Minecraft 1.21.11 and Java 21. They are not permission +to open a real world. Keep the server seed, source worlds and passing source +receipts separate from each new output directory. + +`power-interop.json` pins Base Metals and OreSpawn. `dev-core.json` pins the +two expansions for the combined Eclipse launch. Override dependency locations +through the build properties rather than copying jars into source control. +All current 1.21.11 tests and new worlds require BaseMetals 3.1.2.121111, +pinned by exact SHA-256 in the profile. This is also the metadata minimum. +The qualified local build includes the migration patch; an older public +build is not an equivalent input. + +## Supported Broad Profile + +BaseMetals is mandatory for basePower, baseSteam and baseElectric on Minecraft +1.21.11. The native61 broad driver defaults to `packaged-current`, with all six +family/harness inputs pinned. It also accepts explicit `--mode packaged-current`. +Requests for `--mode both` or `--mode packaged-decoupled` are rejected with a +dependency-policy error before input staging, output creation or server startup. +They are not silent skips or passes. This policy is specific to 1.21.11; +older-version drivers and their qualification profiles are unchanged. +The shared `native12111.read_family_target` reader also requires the BaseMetals +pin for every supported full-family campaign. A missing-dependency control must +first validate the complete six-jar profile, then omit BaseMetals only from a +derived NEW expected-negative runtime; it is never a valid normal family profile. + +Retain the original failed A/B/C2 decoupled reports and their exact hashes. +The clarified support boundary does not relabel historical failures. Future +required broad qualification covers the supported BaseMetals-present profile. + +## Preparing Inputs + +Build Power and the harness, then Steam and Electric against the same Power +jar. Use `migration/native12111.py profile --help` to generate an ignored +packaged profile containing the exact player and harness jar hashes. Stage +only those jars in a disposable runtime. The harness must never enter the +player release jar or the manual client mod list. + +Install `migration/requirements.txt` in a local Python environment. From +`test-rig`, the native drivers expose their required arguments with `--help`: + +| Driver | Evidence | +| --- | --- | +| `native12111_broad.py` | All walkable stations and drill restart witnesses in supported packaged-current | +| `native12111_operations.py` | Complete finite machine jobs, raw stopped saves and restart | +| `native12111_recipes.py` | Native unlock packets, all recipe modes and survival progression | +| `native12111_upgrade.py` | Hash-checked historical/retained worlds, state and connected operation | +| `native12111_actors.py` | Players, nested items, entities, dimensions and stopped entity storage | +| `native12111_networks.py` | Partial/bridge unloads, extended drills and fluid API interoperability | +| `native12111_machines.py` | Harvest, cross-mod oil transport and moving-machine lifecycle | +| `native12111_endurance.py` | Fresh and migrated 30-minute runs, restart and resumed processing | +| `audit_native12111_endurance.py` | Independent replay of every saved endurance checkpoint | + +Run the Python regression checks with: + +```text +python -B -m unittest discover -s migration -p "test_*.py" +``` + +The shared layouts and older semantic oracles deliberately remain unchanged. +The native adapters translate representation and commands, not quantities or +expected results. In particular, native item components must retain their +custom data; a test setup using old item-command syntax is a failed run, +not evidence of lost production items. + +## Evidence Rules + +Keep failed, held and short smoke runs. Do not overwrite them with a later +pass. A smoke duration below 1,800 seconds is not an endurance pass. The +full run also requires at least 18 measured game ticks per second and a +minute of successful operation after a full server-process restart. + +The independent replay uses the same immutable station specification as +the run, currently `src/main/resources/assets/advantageworkstest/works/works-1.13.2.json`. +Its historical filename records the shared layout, not the running platform. +The driver verifies the actual Minecraft/Forge identities in every checkpoint. + +Dimension reports retain the strict failure for Minecraft's independently +proven wolf-health change. Only the exact pinned native control may explain +that difference. Unrelated item loss, ownership changes or missing entities +still fail. See the [port record](../../../docs/1.21.11/PORT-STATUS.md). + +A separate missing-dependency control can run only after the corrected Power, +Steam and Electric metadata jars are frozen and pinned. Use a NEW disposable +Forge 61 runtime with those new jars and all other required dependencies, but +omit BaseMetals. Bound startup to 120 seconds and retain a `MISSING_DEPENDENCY` +receipt, exact jar hashes and logs. Require Forge's missing-BaseMetals refusal +before OreSpawn IMC, world creation or `Done`; reaching any of those is a failed +negative control. Do not launch the old artifacts or treat their OreSpawn crash +as dependency-refusal proof. This is a proposed loader control, not broad gameplay +qualification or a launch performed by the policy tests. + +Client pixel, GUI caption and recipe-book checks are separate from packaged +server operation. See the [client probe instructions](../../client-resources/README.md). +Real player login, live animations, tooltips and human visual acceptance +remain manual checks. diff --git a/test-rig/profiles/native12111/dev-core.json b/test-rig/profiles/native12111/dev-core.json new file mode 100644 index 00000000..c56b50a5 --- /dev/null +++ b/test-rig/profiles/native12111/dev-core.json @@ -0,0 +1,19 @@ +{ + "name": "dev-core", + "mode": "development", + "minecraftVersion": "1.21.11", + "forgeVersion": "61.1.0", + "powerDeobfSha256": "C98D2CF163BE2480D9AFB0023BBD2D92D401AE0D50BEF73191235BF3F83774A7", + "mods": [ + { + "id": "basesteam", + "version": "2.2.16.121111", + "sha256": "F23DDC4ABB36A1C4C7A4205151FBB6DDFD25397E86909E1C0B146C4089B747E0" + }, + { + "id": "baseelectric", + "version": "2.2.16.121111", + "sha256": "54662C9ACA83D6145D5795B1D9E9C252DB5DA085C1EC8CA1C9895B02F2BA0770" + } + ] +} diff --git a/test-rig/profiles/native12111/power-interop.json b/test-rig/profiles/native12111/power-interop.json new file mode 100644 index 00000000..1ff0ccb8 --- /dev/null +++ b/test-rig/profiles/native12111/power-interop.json @@ -0,0 +1,22 @@ +{ + "name": "power-interop", + "mode": "packaged", + "minecraftVersion": "1.21.11", + "forgeVersion": "61.1.0", + "qualificationStatus": "EXPLORATORY_UNQUALIFIED", + "baseMetalsSourceCommit": "616d527e50e4bf0137eeb9c44fa586e34a0452a0", + "mods": [ + { + "id": "basemetals", + "version": "3.1.2.121111", + "path": "../../../../BaseMetals/.verification-basemetals12111/Advantage shared hook/build/libs/BaseMetals-3.1.2.121111.jar", + "sha256": "598D3FB0FF956EBCC67FE477A01CE393C75D0013AF0CE00A79E2072B63E9F2BA" + }, + { + "id": "orespawn", + "version": "4.1.0.121111", + "path": "../../../../MinecraftMineralogy/OS12111/OreSpawn/build/libs/OreSpawn-4.1.0.121111.jar", + "sha256": "DE39929057288CEA110196C9E9D708E129B80BC360A2E53976337FFA6A84A67B" + } + ] +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ActorProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ActorProbe.java index d1aa280a..8132ca4f 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ActorProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ActorProbe.java @@ -20,8 +20,10 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayerGameMode; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.permissions.Permissions; +import static zone.moddev.mc.basepower.util.NbtCompat.containsTag; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.event.TickEvent; + import net.minecraftforge.event.RegisterCommandsEvent; /** Real server player-file adapter and saved-entity witnesses; not a client login. */ @@ -34,9 +36,9 @@ final class ActorProbe { static void register() { if (!MARKER.isFile()) return; ActorProbe probe = new ActorProbe(); - MinecraftForge.EVENT_BUS.addListener(probe::tick); - MinecraftForge.EVENT_BUS.addListener(probe::entityJoining); - MinecraftForge.EVENT_BUS.addListener(probe::starting); + NativeTickEvents.listenLevel(probe::tick); + net.minecraftforge.event.entity.EntityJoinLevelEvent.BUS.addListener(probe::entityJoining); + RegisterCommandsEvent.BUS.addListener(probe::starting); } private boolean frozen(net.minecraft.world.level.Level world) { @@ -45,15 +47,15 @@ private boolean frozen(net.minecraft.world.level.Level world) { static boolean witnessed(Entity entity) { return NativeEntityData.containsString(entity, "AdvWorksActor"); } - private void tick(TickEvent.LevelTickEvent event) { - if (!(event.level instanceof ServerLevel)) return; - if (event.phase == TickEvent.Phase.START && frozen(event.level)) { - NativeEntityHold held = heldActors.computeIfAbsent(event.level, key -> new NativeEntityHold()); - Native117Worlds.entities((ServerLevel) event.level).filter(ActorProbe::witnessed) + private void tick(NativeTickEvents.LevelTickEvent event) { + if (!(event.level() instanceof ServerLevel)) return; + if (event.phase() == NativeTickEvents.Phase.START && frozen(event.level())) { + NativeEntityHold held = heldActors.computeIfAbsent(event.level(), key -> new NativeEntityHold()); + Native117Worlds.entities((ServerLevel) event.level()).filter(ActorProbe::witnessed) .forEach(held::hold); - heldActors.put(event.level, held); - } else if (event.phase == TickEvent.Phase.END) { - NativeEntityHold held = heldActors.remove(event.level); + heldActors.put(event.level(), held); + } else if (event.phase() == NativeTickEvents.Phase.END) { + NativeEntityHold held = heldActors.remove(event.level()); if (held != null) held.restore(); } } @@ -67,7 +69,7 @@ && witnessed(event.getEntity())) { private void starting(RegisterCommandsEvent event) { for (String operation : new String[] {"snapshot", "read", "operate"}) { - event.getDispatcher().register(Commands.literal("advactors").requires(source -> source.hasPermission(4)) + event.getDispatcher().register(Commands.literal("advactors").requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_OWNER)) .then(Commands.literal(operation).then(Commands.argument("label", StringArgumentType.word()).executes(context -> { try { snapshot(context.getSource().getServer(), context.getSource().getLevel(), @@ -96,8 +98,11 @@ private void snapshot(MinecraftServer server, ServerLevel world, String operatio result.addProperty("frozen", frozen(world)); result.addProperty("gameTime", world.getGameTime()); try (java.io.FileInputStream input = new java.io.FileInputStream(new File("AdvantageWorksMigration/level.dat"))) { - CompoundTag data = NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap()).getCompound("Data"); - if (data.contains("Player", 10)) result.add("embedded", encode(data.getCompound("Player"))); + CompoundTag data = NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap()).getCompound("Data").orElseThrow(); + if (data.contains("Player")) { + if (!containsTag(data, "Player", 10)) throw new IllegalStateException("Embedded player is not a compound"); + result.add("embedded", encode(data.getCompound("Player").orElseThrow())); + } } boolean read = operation.equals("read"); result.addProperty("playerAdapter", read); @@ -105,18 +110,17 @@ private void snapshot(MinecraftServer server, ServerLevel world, String operatio GameProfile profile = new GameProfile(UUID.fromString(definition.get("playerUuid").getAsString()), definition.get("playerName").getAsString()); ServerPlayer player = new ServerPlayer(server, world, profile, net.minecraft.server.level.ClientInformation.createDefault()); - if (server.getPlayerList().load(player).isEmpty()) throw new IllegalStateException("No player file loaded"); - result.add("player", encode(player.saveWithoutId(new CompoundTag()))); + Native117PlayerIo.load(server.getPlayerList(), player); + result.add("player", encode(NativeActorIo.saveWithoutId(player))); Native117PlayerIo.save(server.getPlayerList(), player); } JsonArray entities = new JsonArray(); for (Entity entity : Native117Worlds.entities(world).collect(java.util.stream.Collectors.toList())) { if (!witnessed(entity)) continue; if (operation.equals("operate")) entity.canUpdate(true); - CompoundTag nbt = new CompoundTag(); - if (!entity.saveAsPassenger(nbt)) throw new IllegalStateException("Could not serialize actor"); + CompoundTag nbt = NativeActorIo.saveAsPassenger(entity); JsonObject entry = encode(nbt); - entry.addProperty("witness", nbt.getCompound("ForgeData").getString("AdvWorksActor")); + entry.addProperty("witness", nbt.getCompound("ForgeData").orElseThrow().getString("AdvWorksActor").orElseThrow()); entry.addProperty("ticksExisted", entity.tickCount); entities.add(entry); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksCommand.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksCommand.java index c0e10805..a3ccb6b1 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksCommand.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksCommand.java @@ -18,7 +18,7 @@ private AdvantageWorksCommand() {} static void register(CommandDispatcher dispatcher) { dispatcher.register(Commands.literal("advworks") - .requires(source -> source.hasPermission(2)) + .requires(source -> source.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER)) .then(targetCommand("build")) .then(targetCommand("reset")) .then(targetCommand("start")) diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java index dbd87c96..32331aab 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java @@ -7,7 +7,7 @@ @Mod(AdvantageWorksMod.MODID) public final class AdvantageWorksMod { public static final String MODID = "advantageworkstest"; - public static final String VERSION = "1.1.5.121011"; + public static final String VERSION = "1.1.5.121111"; public AdvantageWorksMod() { MigrationProbe.register(); @@ -25,7 +25,7 @@ public AdvantageWorksMod() { MachineOperationalProbe.register(); RedstoneReloadProbe.register(); WorksController.getInstance().loadDefinition(); - MinecraftForge.EVENT_BUS.addListener(this::serverStarting); + RegisterCommandsEvent.BUS.addListener(this::serverStarting); } private void serverStarting(RegisterCommandsEvent event) { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/AssertionChecks.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AssertionChecks.java index 3eca608f..90cf20be 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/AssertionChecks.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AssertionChecks.java @@ -44,19 +44,19 @@ static boolean compare(double actual, String operator, Number expected) { } static int fluidAmount(CompoundTag tile, String key, String expectedFluid) { - if (!tile.contains(key, 10)) throw new IllegalArgumentException("Missing tank compound " + key); - CompoundTag tank = tile.getCompound(key); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(tile, key, 10)) throw new IllegalArgumentException("Missing tank compound " + key); + CompoundTag tank = tile.getCompound(key).orElseThrow(); // Forge writes an explicit empty marker; a missing tank is not an empty tank. - if (tank.contains("Empty", 1) && tank.getBoolean("Empty") + if (zone.moddev.mc.basepower.util.NbtCompat.containsTag(tank, "Empty", 1) && tank.getBoolean("Empty").orElseThrow() && !tank.contains("Amount") && !tank.contains("FluidName")) return 0; if (tank.contains("Empty")) throw new IllegalArgumentException("Contradictory empty tank marker at " + key); - if (!tank.contains("Amount", 3) || !tank.contains("FluidName", 8)) { + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(tank, "Amount", 3) || !zone.moddev.mc.basepower.util.NbtCompat.containsTag(tank, "FluidName", 8)) { throw new IllegalArgumentException("Missing tank amount or fluid identity at " + key); } - int amount = tank.getInt("Amount"); - if (amount < 0 || (amount > 0 && tank.getString("FluidName").isEmpty())) { + int amount = tank.getInt("Amount").orElseThrow(); + if (amount < 0 || (amount > 0 && tank.getString("FluidName").orElseThrow().isEmpty())) { throw new IllegalArgumentException("Invalid tank contents at " + key); } - return expectedFluid == null || expectedFluid.equals(tank.getString("FluidName")) ? amount : 0; + return expectedFluid == null || expectedFluid.equals(tank.getString("FluidName").orElseThrow()) ? amount : 0; } } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ConnectedProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ConnectedProbe.java index c5d83592..95312ea1 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ConnectedProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ConnectedProbe.java @@ -16,7 +16,7 @@ import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.event.TickEvent; + import net.minecraftforge.event.RegisterCommandsEvent; import zone.moddev.mc.advantageworks.connected.ConnectedSession; @@ -29,9 +29,9 @@ final class ConnectedProbe implements ConnectedSession.Port { static void register() { if (!new File("advantage-connected.allow").isFile()) return; ConnectedProbe probe = new ConnectedProbe(); - MinecraftForge.EVENT_BUS.addListener(probe::tick); - MinecraftForge.EVENT_BUS.addListener(probe::chunkLoaded); - MinecraftForge.EVENT_BUS.addListener(probe::starting); + NativeTickEvents.listenLevel(probe::tick); + net.minecraftforge.event.level.ChunkEvent.Load.BUS.addListener(probe::chunkLoaded); + RegisterCommandsEvent.BUS.addListener(probe::starting); } private ConnectedSession get(ServerLevel current) throws Exception { @@ -43,11 +43,11 @@ private ConnectedSession get(ServerLevel current) throws Exception { return session; } - private void tick(TickEvent.LevelTickEvent event) { - if (event.level.isClientSide || Native117Worlds.legacyId(event.level.dimension()) != 0) return; + private void tick(NativeTickEvents.LevelTickEvent event) { + if (event.level().isClientSide() || Native117Worlds.legacyId(event.level().dimension()) != 0) return; try { - ConnectedSession current = get((ServerLevel) event.level); - if (event.phase == TickEvent.Phase.START) current.start(); else current.end(); + ConnectedSession current = get((ServerLevel) event.level()); + if (event.phase() == NativeTickEvents.Phase.START) current.start(); else current.end(); } catch (Exception ex) { throw new IllegalStateException("Connected probe failed", ex); } } @@ -60,7 +60,7 @@ private void chunkLoaded(net.minecraftforge.event.level.ChunkEvent.Load event) { } private void starting(RegisterCommandsEvent event) { - event.getDispatcher().register(Commands.literal("advconnected").requires(s -> s.hasPermission(4)) + event.getDispatcher().register(Commands.literal("advconnected").requires(s -> s.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_OWNER)) .then(Commands.literal("snapshot").then(Commands.argument("label", StringArgumentType.word()).executes(c -> { execute(c.getSource().getLevel(), "snapshot", StringArgumentType.getString(c, "label"), 0); return 1; }))) @@ -113,7 +113,8 @@ private void execute(ServerLevel current, String operation, String label, int ti entry.addProperty("nbt", tag.toString()); entry.addProperty("nbtBase64", java.util.Base64.getEncoder().encodeToString(bytes.toByteArray())); entry.addProperty("objectIdentity", System.identityHashCode(tile)); - entry.addProperty("witness", tag.getCompound("ForgeData").getString("AdvConnectedWitness")); + entry.addProperty("witness", tag.getCompound("ForgeData") + .flatMap(data -> data.getString("AdvConnectedWitness")).orElse("")); } result.add(entry); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/DimensionEntityProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/DimensionEntityProbe.java index 5582a7e3..7d367c97 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/DimensionEntityProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/DimensionEntityProbe.java @@ -9,8 +9,7 @@ import com.google.gson.stream.JsonToken; import com.mojang.authlib.GameProfile; import com.mojang.brigadier.arguments.StringArgumentType; -import io.netty.util.concurrent.Future; -import io.netty.util.concurrent.GenericFutureListener; +import io.netty.channel.ChannelFutureListener; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.StringReader; @@ -32,7 +31,7 @@ import net.minecraft.world.level.block.BedBlock; import net.minecraft.commands.Commands; import net.minecraft.world.entity.Entity; -import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.Leashable; import net.minecraft.world.entity.decoration.LeashFenceKnotEntity; import net.minecraft.world.entity.decoration.HangingEntity; import net.minecraft.world.entity.item.ItemEntity; @@ -48,14 +47,18 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayerGameMode; import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.network.PacketSendListener; import net.minecraft.core.Direction; import net.minecraft.core.BlockPos; import net.minecraft.world.level.Level; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.permissions.Permissions; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.storage.LevelData; +import net.minecraft.world.level.storage.TagValueInput; +import static zone.moddev.mc.basepower.util.NbtCompat.containsTag; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.level.LevelEvent; -import net.minecraftforge.event.TickEvent; + import net.minecraftforge.event.RegisterCommandsEvent; /** Native additional-dimension player and entity observations, without client acceptance claims. */ @@ -75,14 +78,14 @@ final class DimensionEntityProbe { static void register() { if (!MARKER.isFile()) return; DimensionEntityProbe probe = new DimensionEntityProbe(); - MinecraftForge.EVENT_BUS.addListener(probe::load); - MinecraftForge.EVENT_BUS.addListener(probe::tick); - MinecraftForge.EVENT_BUS.addListener(probe::entityJoining); - MinecraftForge.EVENT_BUS.addListener(probe::starting); + LevelEvent.Load.BUS.addListener(probe::load); + NativeTickEvents.listenLevel(probe::tick); + net.minecraftforge.event.entity.EntityJoinLevelEvent.BUS.addListener(probe::entityJoining); + RegisterCommandsEvent.BUS.addListener(probe::starting); } private static boolean marked(Level world) { - return MARKER.isFile() && !world.isClientSide + return MARKER.isFile() && !world.isClientSide() && "AdvantageWorksMigration".equals(world.getServer().getWorldData().getLevelName()); } @@ -106,27 +109,27 @@ private void load(LevelEvent.Load event) { } catch (Exception ex) { throw new IllegalStateException("Dimension load preparation failed", ex); } } - private void tick(TickEvent.LevelTickEvent event) { - if (!marked(event.level) || !DEFINITION.isFile()) return; - if (event.phase == TickEvent.Phase.START) { + private void tick(NativeTickEvents.LevelTickEvent event) { + if (!marked(event.level()) || !DEFINITION.isFile()) return; + if (event.phase() == NativeTickEvents.Phase.START) { try { JsonObject definition = definition(); int selected = dimension(definition); - if (event.level.dimension().equals(Level.OVERWORLD) && nativeOnLoad(definition)) { - ServerLevel selectedWorld = event.level.getServer().getLevel(Native117Worlds.key(selected)); + if (event.level().dimension().equals(Level.OVERWORLD) && nativeOnLoad(definition)) { + ServerLevel selectedWorld = event.level().getServer().getLevel(Native117Worlds.key(selected)); if (selectedWorld == null || !marked(selectedWorld)) throw new IllegalStateException("Dimension unavailable"); prepare(selectedWorld); } - if (Native117Worlds.legacyId(event.level.dimension()) != selected) return; + if (Native117Worlds.legacyId(event.level().dimension()) != selected) return; // Pin/load before either frozen observation or normal native initialization. - prepare((ServerLevel) event.level); - if (!frozen(event.level, definition)) return; - NativeEntityHold hold = held.computeIfAbsent(event.level, key -> new NativeEntityHold()); - for (Entity entity : witnesses((ServerLevel) event.level)) hold.hold(entity); - held.put(event.level, hold); + prepare((ServerLevel) event.level()); + if (!frozen(event.level(), definition)) return; + NativeEntityHold hold = held.computeIfAbsent(event.level(), key -> new NativeEntityHold()); + for (Entity entity : witnesses((ServerLevel) event.level())) hold.hold(entity); + held.put(event.level(), hold); } catch (Exception ex) { throw new IllegalStateException("Dimension tick preparation failed", ex); } - } else if (event.phase == TickEvent.Phase.END) { - NativeEntityHold hold = held.remove(event.level); + } else if (event.phase() == NativeTickEvents.Phase.END) { + NativeEntityHold hold = held.remove(event.level()); if (hold != null) hold.restore(); } } @@ -143,7 +146,7 @@ private void entityJoining(net.minecraftforge.event.entity.EntityJoinLevelEvent private void starting(RegisterCommandsEvent event) { for (String operation : new String[] {"read", "snapshot", "operate", "respawn"}) { - event.getDispatcher().register(Commands.literal("advdimensions").requires(source -> source.hasPermission(4)) + event.getDispatcher().register(Commands.literal("advdimensions").requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_OWNER)) .then(Commands.literal(operation).then(Commands.argument("label", StringArgumentType.word()).executes(context -> { try { execute(context.getSource().getServer(), operation, StringArgumentType.getString(context, "label")); @@ -293,7 +296,7 @@ private static void collect(Entity entity, Set seen, List result private static ServerPlayer readPlayer(MinecraftServer server, ServerLevel world, JsonObject definition) { ServerPlayer player = new ServerPlayer(server, world, new GameProfile(UUID.fromString(definition.get("playerUuid").getAsString()), definition.get("playerName").getAsString()), net.minecraft.server.level.ClientInformation.createDefault()); - if (server.getPlayerList().load(player).isEmpty()) throw new IllegalStateException("No player file loaded"); + Native117PlayerIo.load(server.getPlayerList(), player); if (Native117Worlds.legacyId(player.level().dimension()) != dimension(definition) || player.level() != world) { throw new IllegalStateException("Player loaded in wrong dimension"); } @@ -303,7 +306,7 @@ private static ServerPlayer readPlayer(MinecraftServer server, ServerLevel world private JsonObject snapshot(MinecraftServer server, ServerLevel world, JsonObject definition, String operation) throws Exception { JsonObject result = new JsonObject(); result.add("definition", definition); result.addProperty("operation", operation); result.addProperty("dimension", Native117Worlds.legacyId(world.dimension())); - result.addProperty("worldDimensionKey", world.dimension().location().toString()); + result.addProperty("worldDimensionKey", world.dimension().identifier().toString()); result.addProperty("nativeEntityContextVersion", 1); result.addProperty("gameTime", world.getGameTime()); result.addProperty("frozen", frozen(world, definition)); result.addProperty("nativeOnLoad", nativeOnLoad(definition)); @@ -312,7 +315,7 @@ private JsonObject snapshot(MinecraftServer server, ServerLevel world, JsonObjec if (read) { ServerPlayer player = readPlayer(server, world, definition); result.addProperty("playerWorldDimension", Native117Worlds.legacyId(player.level().dimension())); - result.add("player", encode(player.saveWithoutId(new CompoundTag()))); + result.add("player", encode(NativeActorIo.saveWithoutId(player))); if (operation.equals("respawn")) result.add("respawn", respawn(server, world, definition, player)); else Native117PlayerIo.save(server.getPlayerList(), player); } @@ -321,7 +324,7 @@ private JsonObject snapshot(MinecraftServer server, ServerLevel world, JsonObjec JsonObject context = Native117EntityContext.observe(entity, world); // This native writer includes type ID and works for both roots and riding passengers. CompoundTag nbt = entityNbt(entity); - JsonObject entry = encode(nbt); entry.addProperty("witness", nbt.getCompound("ForgeData").getString(WITNESS)); + JsonObject entry = encode(nbt); entry.addProperty("witness", nbt.getCompound("ForgeData").orElseThrow().getString(WITNESS).orElseThrow()); entry.add("worldDimensionKey", context.get("worldDimensionKey")); entry.add("sameWorld", context.get("sameWorld")); entry.add("entityWorlds", context.get("entityWorlds")); @@ -331,7 +334,7 @@ private JsonObject snapshot(MinecraftServer server, ServerLevel world, JsonObjec entry.add("vehicleUuid", entity.getVehicle() == null ? JsonNull.INSTANCE : JSON.toJsonTree(entity.getVehicle().getUUID().toString())); JsonArray passengers = new JsonArray(); for (Entity passenger : entity.getPassengers()) passengers.add(passenger.getUUID().toString()); entry.add("passengerUuids", passengers); - Entity leash = entity instanceof Mob ? ((Mob) entity).getLeashHolder() : null; + Entity leash = entity instanceof Leashable leashable ? leashable.getLeashHolder() : null; entry.add("leashHolderUuid", leash == null ? JsonNull.INSTANCE : JSON.toJsonTree(leash.getUUID().toString())); entry.add("leashHolderType", leash == null ? JsonNull.INSTANCE : JSON.toJsonTree(net.minecraftforge.registries.ForgeRegistries.ENTITY_TYPES.getKey(leash.getType()).toString())); BlockPos leashPos = leash instanceof net.minecraft.world.entity.decoration.BlockAttachedEntity attached @@ -370,24 +373,25 @@ private static JsonObject respawn(MinecraftServer server, ServerLevel world, Jso destination.setBlock(SPAWN.east(), Blocks.RED_BED.defaultBlockState().setValue(BedBlock.FACING, Direction.EAST) .setValue(BedBlock.PART, net.minecraft.world.level.block.state.properties.BedPart.HEAD), 2); } - player.setRespawnPosition(Level.OVERWORLD, SPAWN, 0.0F, bedMode.equals("forced"), false); + player.setRespawnPosition(new ServerPlayer.RespawnConfig( + LevelData.RespawnData.of(Level.OVERWORLD, SPAWN, 0.0F, 0.0F), bedMode.equals("forced")), false); JsonObject result = new JsonObject(); result.addProperty("scope", "server-only-dummy-network"); - result.addProperty("handler", "ServerPlayNetHandler.PERFORM_RESPAWN"); + result.addProperty("handler", "ServerGamePacketListenerImpl.PERFORM_RESPAWN"); result.addProperty("keepInventory", keep); result.addProperty("bedMode", bedMode); - CompoundTag before = player.saveWithoutId(new CompoundTag()); + CompoundTag before = NativeActorIo.saveWithoutId(player); result.addProperty("fromDimension", Native117Worlds.legacyId(player.level().dimension())); result.add("before", encode(before)); JsonObject diagnostics = new JsonObject(); result.add("spawnDiagnostics", diagnostics); diagnostics.add("before", spawnDiagnostics(player, before)); Set existing = Collections.newSetFromMap(new IdentityHashMap()); Native117Worlds.entities(world).forEach(existing::add); - boolean oldSource = world.getGameRules().getBoolean(net.minecraft.world.level.GameRules.RULE_KEEPINVENTORY), - oldTarget = destination.getGameRules().getBoolean(net.minecraft.world.level.GameRules.RULE_KEEPINVENTORY); + boolean oldSource = world.getGameRules().get(GameRules.KEEP_INVENTORY), + oldTarget = destination.getGameRules().get(GameRules.KEEP_INVENTORY); ServerPlayer replacement = null; try { - world.getGameRules().getRule(net.minecraft.world.level.GameRules.RULE_KEEPINVENTORY).set(keep, server); - destination.getGameRules().getRule(net.minecraft.world.level.GameRules.RULE_KEEPINVENTORY).set(keep, server); + world.getGameRules().set(GameRules.KEEP_INVENTORY, keep, server); + destination.getGameRules().set(GameRules.KEEP_INVENTORY, keep, server); OfflineConnection connection = new OfflineConnection(server, player); player.setHealth(0); player.die(player.damageSources().generic()); - CompoundTag afterDeath = player.saveWithoutId(new CompoundTag()); + CompoundTag afterDeath = NativeActorIo.saveWithoutId(player); result.add("afterDeath", encode(afterDeath)); diagnostics.add("afterDeath", spawnDiagnostics(player, afterDeath)); if (player.wonGame) throw new IllegalStateException("Expected ordinary death respawn, not queued End exit"); @@ -395,15 +399,15 @@ private static JsonObject respawn(MinecraftServer server, ServerLevel world, Jso replacement = server.getPlayerList().getPlayer(player.getUUID()); if (replacement == null || replacement == player) throw new IllegalStateException("Native respawn handler did not replace player"); new OfflineConnection(server, replacement); - CompoundTag after = replacement.saveWithoutId(new CompoundTag()); + CompoundTag after = NativeActorIo.saveWithoutId(replacement); result.add("after", encode(after)); diagnostics.add("after", spawnDiagnostics(replacement, after)); result.addProperty("toDimension", Native117Worlds.legacyId(replacement.level().dimension())); - BlockPos bed = replacement.getRespawnPosition(); + BlockPos bed = replacement.getRespawnConfig() == null ? null : replacement.getRespawnConfig().respawnData().pos(); result.add("bedLocation", bed == null ? JsonNull.INSTANCE : JSON.toJsonTree(new int[] {bed.getX(), bed.getY(), bed.getZ()})); JsonArray drops = new JsonArray(); for (Entity entity : Native117Worlds.entities(world).collect(java.util.stream.Collectors.toList())) if (entity instanceof ItemEntity && !existing.contains(entity)) { - CompoundTag nbt = new CompoundTag(); if (!entity.saveAsPassenger(nbt)) throw new IllegalStateException("Could not serialize death drop"); + CompoundTag nbt = NativeActorIo.saveAsPassenger(entity); drops.add(encode(nbt)); } result.add("drops", drops); @@ -412,47 +416,58 @@ private static JsonObject respawn(MinecraftServer server, ServerLevel world, Jso try { if (replacement != null) server.getPlayerList().remove(replacement); } finally { - world.getGameRules().getRule(net.minecraft.world.level.GameRules.RULE_KEEPINVENTORY).set(oldSource, server); - destination.getGameRules().getRule(net.minecraft.world.level.GameRules.RULE_KEEPINVENTORY).set(oldTarget, server); + world.getGameRules().set(GameRules.KEEP_INVENTORY, oldSource, server); + destination.getGameRules().set(GameRules.KEEP_INVENTORY, oldTarget, server); } } } - private static JsonObject spawnDiagnostics(ServerPlayer player, CompoundTag nbt) { + static JsonObject spawnDiagnostics(ServerPlayer player, CompoundTag nbt) throws Exception { JsonObject result = new JsonObject(); result.addProperty("queryDimension", 0); result.addProperty("playerWorldDimension", Native117Worlds.legacyId(player.level().dimension())); - result.addProperty("spawnDimension", Native117Worlds.legacyId(player.getRespawnDimension())); - BlockPos bed = player.getRespawnPosition(); + ServerPlayer.RespawnConfig config = player.getRespawnConfig(); + result.addProperty("spawnDimension", Native117Worlds.legacyId(config == null ? Level.OVERWORLD : config.respawnData().dimension())); + BlockPos bed = config == null ? null : config.respawnData().pos(); result.add("bedLocation", bed == null ? JsonNull.INSTANCE : JSON.toJsonTree(new int[] {bed.getX(), bed.getY(), bed.getZ()})); - result.addProperty("spawnForced", player.isRespawnForced()); - JsonObject spawn = new JsonObject(); - for (String key : new String[] {"SpawnX", "SpawnY", "SpawnZ"}) { - spawn.add(key, nbt.contains(key, 99) ? JSON.toJsonTree(nbt.getInt(key)) : JsonNull.INSTANCE); + result.add("spawnForced", config == null ? JsonNull.INSTANCE : JSON.toJsonTree(config.forced())); + if (config == null) { + if (nbt.contains("respawn")) throw new IllegalStateException("Serialized respawn exists without native configuration"); + result.add("serializedSpawn", JsonNull.INSTANCE); + result.add("serializedRespawn", JsonNull.INSTANCE); + return result; } - spawn.add("SpawnForced", nbt.contains("SpawnForced", 99) ? JSON.toJsonTree(nbt.getBoolean("SpawnForced")) : JsonNull.INSTANCE); - spawn.add("SpawnDimension", nbt.contains("SpawnDimension", 8) ? JSON.toJsonTree(nbt.getString("SpawnDimension")) - : nbt.contains("SpawnDimension", 99) ? JSON.toJsonTree(nbt.getInt("SpawnDimension")) : JsonNull.INSTANCE); + if (!containsTag(nbt, "respawn", 10)) throw new IllegalStateException("Missing native serialized respawn compound"); + ServerPlayer.RespawnConfig decoded = TagValueInput.create(NativeActorIo.reporter("respawn diagnostics"), + player.registryAccess(), nbt).read("respawn", ServerPlayer.RespawnConfig.CODEC).orElseThrow(); + if (!config.equals(decoded)) throw new IllegalStateException("Serialized respawn differs from native configuration"); + CompoundTag saved = nbt.getCompound("respawn").orElseThrow(); + JsonObject spawn = new JsonObject(); + spawn.addProperty("dimension", saved.getString("dimension").orElseThrow()); + spawn.add("pos", JSON.toJsonTree(saved.getIntArray("pos").orElseThrow())); + spawn.addProperty("yaw", saved.getFloat("yaw").orElseThrow()); + spawn.addProperty("pitch", saved.getFloat("pitch").orElseThrow()); + // The native codec omits forced=false. Retain that absence in the raw evidence. + if (saved.contains("forced")) spawn.addProperty("forced", saved.getBoolean("forced").orElseThrow()); result.add("serializedSpawn", spawn); + result.add("serializedRespawn", encode(saved)); return result; } private static final class OfflineConnection extends ServerGamePacketListenerImpl { - OfflineConnection(MinecraftServer server, ServerPlayer player) { super(server, new Connection(PacketFlow.SERVERBOUND), player, WorksOfflinePlayer.cookie(player)); } + OfflineConnection(MinecraftServer server, ServerPlayer player) { super(server, new Connection(PacketFlow.SERVERBOUND) {}, player, WorksOfflinePlayer.cookie(player)); } @Override public void send(Packet packet) { /* Deliberately no socket or client login. */ } - @Override public void send(Packet packet, PacketSendListener listener) { + @Override public void send(Packet packet, ChannelFutureListener listener) { // Death-message packets use this overload; no native network work is queued. } } private static CompoundTag entityNbt(Entity entity) { if (entity instanceof LeashFenceKnotEntity) { - CompoundTag nbt = entity.saveWithoutId(new CompoundTag()); + CompoundTag nbt = NativeActorIo.saveWithoutId(entity); nbt.putString("id", net.minecraftforge.registries.ForgeRegistries.ENTITY_TYPES.getKey(entity.getType()).toString()); return nbt; } - CompoundTag nbt = new CompoundTag(); - if (!entity.saveAsPassenger(nbt)) throw new IllegalStateException("Could not serialize dimension witness"); - return nbt; + return NativeActorIo.saveAsPassenger(entity); } private static JsonObject encode(CompoundTag nbt) throws Exception { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java index 8a89f04c..5607809d 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java @@ -7,17 +7,17 @@ import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.ai.attributes.Attributes; import net.minecraft.world.entity.item.ItemEntity; -import net.minecraft.world.entity.monster.Husk; +import net.minecraft.world.entity.monster.zombie.Husk; import net.minecraft.world.entity.player.Player; import net.minecraft.world.Container; import net.minecraft.world.item.ItemStack; import net.minecraft.core.Direction; import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.scores.PlayerTeam; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.world.phys.AABB; import net.minecraft.core.BlockPos; -import net.minecraft.world.level.GameRules; +import net.minecraft.world.level.gamerules.GameRules; import net.minecraftforge.fluids.FluidStack; import net.minecraftforge.fluids.capability.IFluidHandler; import net.minecraftforge.registries.ForgeRegistries; @@ -210,7 +210,7 @@ private static void still(List cases) { + "mutation. The 48 finite mB then operate on natural ticks, making 24 mB refined oil for 384 electricity.", 80, c -> { c.block(8, Y, Z, EA + "electric_still"); c.electricSupply(7, Y, Z, 1000); - net.minecraft.world.level.material.Fluid oil = ForgeRegistries.FLUIDS.getValue(ResourceLocation.parse("basepower:crude_oil")); + net.minecraft.world.level.material.Fluid oil = ForgeRegistries.FLUIDS.getValue(Identifier.parse("basepower:crude_oil")); c.require(oil != null, "Crude oil exists for faced automation"); int stored = 0; for (Direction face : Direction.values()) { @@ -229,7 +229,7 @@ private static void still(List cases) { for (Direction face : Direction.values()) { FluidStack simulated = fluids(c, 8, face).drain(24, IFluidHandler.FluidAction.SIMULATE); equal(c, "face." + face + ".drainable", simulated.getAmount(), 24); - c.require(ResourceLocation.parse("basepower:refined_oil").equals(net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(simulated.getFluid())), "Face offers the real refined product " + face); + c.require(Identifier.parse("basepower:refined_oil").equals(net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(simulated.getFluid())), "Face offers the real refined product " + face); equal(c, "face." + face + ".drainSimulationUnchanged", tankAmount(c, 8, 1), 24); } })); @@ -406,7 +406,7 @@ private static void hydro(List cases) { int turbines = 0; for (Entity actor : c.world.getEntitiesOfClass(Entity.class, WorksBlockData.bounds(c.at(0, 0, 0), c.at(31, 31, 31)))) { - if (!ResourceLocation.parse(EA + "hydroturbine").equals(net.minecraftforge.registries.ForgeRegistries.ENTITY_TYPES.getKey(actor.getType()))) continue; + if (!Identifier.parse(EA + "hydroturbine").equals(net.minecraftforge.registries.ForgeRegistries.ENTITY_TYPES.getKey(actor.getType()))) continue; turbines++; c.record("hydro.motion/" + actor.getUUID(), actor.getDeltaMovement().toString()); c.require(actor.getDeltaMovement().lengthSqr() == 0, @@ -427,7 +427,7 @@ private static void hydro(List cases) { c.record("setup.previousDayTime", previousTime[0]); c.record("setup.weatherFactor", weatherFactor[0]); c.world.setDayTime(6000); - daylightCycle[0] = c.world.getGameRules().getBoolean(GameRules.RULE_DAYLIGHT); + daylightCycle[0] = c.world.getGameRules().get(GameRules.ADVANCE_TIME); c.record("setup.daylightCycle", daylightCycle[0]); c.record("daylight.constructionGameTime", c.world.getGameTime()); }, c -> { @@ -500,10 +500,10 @@ private static void steamGeneration(List cases) { close(c, "positive.generated", c.energy(8, Y, Z, ELECTRICITY), expected, 0.001f); close(c, "empty.generated", c.energy(16, Y, Z, ELECTRICITY), 0, 0.001f); close(c, "full.electricity", c.energy(24, Y, Z, ELECTRICITY), 1000, 0.001f); - c.record("positive.steamRemaining", c.nbt(8, Y, Z).getFloat("steam")); - close(c, "positive.steamExhausted", c.nbt(8, Y, Z).getFloat("steam"), 0, 0.001f); + c.record("positive.steamRemaining", c.nbt(8, Y, Z).getFloat("steam").orElseThrow()); + close(c, "positive.steamExhausted", c.nbt(8, Y, Z).getFloat("steam").orElseThrow(), 0, 0.001f); int fullUpdates = ElectricOperationalRates.updateDelays(c.activationTick, phase(c, 24, Y, Z), 40).size(); - close(c, "full.steamAfterDecay", c.nbt(24, Y, Z).getFloat("steam"), + close(c, "full.steamAfterDecay", c.nbt(24, Y, Z).getFloat("steam").orElseThrow(), ElectricOperationalRates.fullGeneratorSteam(32, fullUpdates), 0.001f); })); cases.add(new MachineScenario("electric/steam-generator-supply", @@ -584,9 +584,9 @@ private static void turret(List cases) { if (mode != 1) c.power(8, 1, Z, ELECTRICITY, mode == 2 ? 999 : 1500); if (mode == 3) for (int y = 1; y <= 4; y++) for (int z = Z - 2; z <= Z + 2; z++) c.block(12, y, z, "minecraft:stone"); - Husk husk = EntityType.HUSK.create(c.world); + Husk husk = EntityType.HUSK.create(c.world, net.minecraft.world.entity.EntitySpawnReason.COMMAND); c.require(husk != null, "Created a real hostile target"); - husk.moveTo(c.at(16, 1, Z).getX() + 0.5, c.at(16, 1, Z).getY(), c.at(16, 1, Z).getZ() + 0.5, 0, 0); + husk.snapTo(c.at(16, 1, Z).getX() + 0.5, c.at(16, 1, Z).getY(), c.at(16, 1, Z).getZ() + 0.5, 0, 0); husk.setNoAi(true); husk.setPersistenceRequired(); husk.setNoGravity(true); @@ -633,16 +633,16 @@ private static void turret(List cases) { UUID identity = UUID.randomUUID(); String name = "EOp" + identity.toString().substring(0, 8); WorksOfflinePlayer player = new WorksOfflinePlayer(c.world, new GameProfile(identity, name)); - player.moveTo(c.at(8, 1, 16).getX() + 0.5, c.at(8, 1, 16).getY(), c.at(8, 1, 16).getZ() + 0.5, 0, 0); + player.snapTo(c.at(8, 1, 16).getX() + 0.5, c.at(8, 1, 16).getY(), c.at(8, 1, 16).getZ() + 0.5, 0, 0); owner[0] = player; team[0] = c.world.getScoreboard().addPlayerTeam(name); c.world.getScoreboard().addPlayerToTeam(player.getScoreboardName(), team[0]); c.world.addNewPlayer(player); c.invoke(8, 1, Z, "setOwner", new Class[] {Player.class}, player); - Husk husk = EntityType.HUSK.create(c.world); + Husk husk = EntityType.HUSK.create(c.world, net.minecraft.world.entity.EntitySpawnReason.COMMAND); c.require(husk != null, "Created ownership target"); victim[0] = husk; - husk.moveTo(c.at(16, 1, Z).getX() + 0.5, c.at(16, 1, Z).getY(), c.at(16, 1, Z).getZ() + 0.5, 0, 0); + husk.snapTo(c.at(16, 1, Z).getX() + 0.5, c.at(16, 1, Z).getY(), c.at(16, 1, Z).getZ() + 0.5, 0, 0); husk.setNoAi(true); husk.setNoGravity(true); husk.setPersistenceRequired(); @@ -686,7 +686,7 @@ private static void turretRoom(MachineContext c) { private static int ledLight(MachineContext c, int x, int y, int z) { var pos = c.at(x, y, z); var state = c.world.getBlockState(pos); - c.require(ResourceLocation.parse(EA + "led_bar").equals(ForgeRegistries.BLOCKS.getKey(state.getBlock())), + c.require(Identifier.parse(EA + "led_bar").equals(ForgeRegistries.BLOCKS.getKey(state.getBlock())), "Light observation targets the real LED"); return state.getLightEmission(c.world, pos); } @@ -732,7 +732,7 @@ private static int output(MachineContext c, int x, int first, int slots, String } private static int stackCount(ItemStack stack, String id) { - return stack == null || stack.isEmpty() || !ResourceLocation.parse(id).equals(net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(stack.getItem())) ? 0 : stack.getCount(); + return stack == null || stack.isEmpty() || !Identifier.parse(id).equals(net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(stack.getItem())) ? 0 : stack.getCount(); } private static IFluidHandler fluids(MachineContext c, int x) { @@ -749,7 +749,7 @@ private static IFluidHandler fluids(MachineContext c, int x, Direction face) { private static void reserve(MachineContext c, int x, int y, int z, String method, String id, int amount) { cyano.poweradvantage.api.fluid.FluidTank tank = (cyano.poweradvantage.api.fluid.FluidTank) c.invoke(x, y, z, method, new Class[0]); - net.minecraft.world.level.material.Fluid fluid = ForgeRegistries.FLUIDS.getValue(ResourceLocation.parse(id)); + net.minecraft.world.level.material.Fluid fluid = ForgeRegistries.FLUIDS.getValue(Identifier.parse(id)); c.require(fluid != null && amount >= 0 && amount <= tank.getCapacity(), "Valid finite fluid reserve " + id); tank.setFluid(new cyano.poweradvantage.api.fluid.FluidStack(fluid, amount)); c.tile(x, y, z).setChanged(); @@ -769,11 +769,11 @@ private static int tankAmount(MachineContext c, int x, int tank) { private static void fluidIs(MachineContext c, int x, int tank, String id) { FluidStack fluid = fluids(c, x).getFluidInTank(tank); c.record("fluid." + x + "." + tank, String.valueOf(net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid.getFluid()))); - c.require(ResourceLocation.parse(id).equals(net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid.getFluid())), "Tank contains " + id); + c.require(Identifier.parse(id).equals(net.minecraftforge.registries.ForgeRegistries.FLUIDS.getKey(fluid.getFluid())), "Tank contains " + id); } private static void rejectFluid(MachineContext c, int x, String id, int amount) { - net.minecraft.world.level.material.Fluid fluid = ForgeRegistries.FLUIDS.getValue(ResourceLocation.parse(id)); + net.minecraft.world.level.material.Fluid fluid = ForgeRegistries.FLUIDS.getValue(Identifier.parse(id)); c.require(fluid != null, "Negative-control fluid exists: " + id); equal(c, "invalidFluid.accepted", fluids(c, x).fill(new FluidStack(fluid, amount), IFluidHandler.FluidAction.EXECUTE), 0); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/LifecycleProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/LifecycleProbe.java index da4e0e84..73da1bc6 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/LifecycleProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/LifecycleProbe.java @@ -28,7 +28,7 @@ import net.minecraft.world.level.chunk.LevelChunk; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.level.ChunkEvent; -import net.minecraftforge.event.TickEvent; + import net.minecraftforge.fml.ModList; import net.minecraftforge.event.RegisterCommandsEvent; import zone.moddev.mc.basepower.conduitnetwork.ConduitRegistry; @@ -179,15 +179,15 @@ static void register() { } catch (Exception error) { throw new IllegalStateException("Invalid lifecycle fixture", error); } - MinecraftForge.EVENT_BUS.addListener(probe::starting); - MinecraftForge.EVENT_BUS.addListener(probe::tick); - MinecraftForge.EVENT_BUS.addListener(probe::unload); - MinecraftForge.EVENT_BUS.addListener(probe::load); + RegisterCommandsEvent.BUS.addListener(probe::starting); + NativeTickEvents.listenLevel(probe::tick); + ChunkEvent.Unload.BUS.addListener(probe::unload); + ChunkEvent.Load.BUS.addListener(probe::load); } private void starting(RegisterCommandsEvent event) { event.getDispatcher().register(Commands.literal("advlifecycle") - .requires(source -> source.hasPermission(4)) + .requires(source -> source.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_OWNER)) .then(Commands.argument("operation", StringArgumentType.word()) .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { WorksController.getInstance().requireAuthorizedWorld(context.getSource().getServer()); @@ -233,7 +233,7 @@ private void execute(ServerLevel world, String operation, String label) { BlockPos source = position(oil.source); if (!world.isEmptyBlock(source)) throw new IllegalStateException("Oil source is not empty: " + oil.id); net.minecraft.world.item.Item item = net.minecraftforge.registries.ForgeRegistries.ITEMS.getValue( - net.minecraft.resources.ResourceLocation.parse(oil.bucket)); + net.minecraft.resources.Identifier.parse(oil.bucket)); if (!(item instanceof net.minecraft.world.item.BucketItem) || !((net.minecraft.world.item.BucketItem) item).emptyContents(null, world, source, null)) throw new IllegalStateException("Bucket placement failed: " + oil.id); @@ -336,8 +336,8 @@ private boolean roundTripPrerequisites(RoundTripRequest request) { LevelChunk chunk = world.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4); BlockEntity tile = existingTile(world, pos); CompoundTag saved = tile == null ? new CompoundTag() : WorksBlockData.save(tile); - boolean hasTarget = saved.contains("targetX", 99) && saved.contains("targetY", 99) && saved.contains("targetZ", 99); - BlockPos target = hasTarget ? new BlockPos(saved.getInt("targetX"), saved.getInt("targetY"), saved.getInt("targetZ")) : pos; + boolean hasTarget = zone.moddev.mc.basepower.util.NbtCompat.containsTag(saved, "targetX", 99) && zone.moddev.mc.basepower.util.NbtCompat.containsTag(saved, "targetY", 99) && zone.moddev.mc.basepower.util.NbtCompat.containsTag(saved, "targetZ", 99); + BlockPos target = hasTarget ? new BlockPos(saved.getInt("targetX").orElseThrow(), saved.getInt("targetY").orElseThrow(), saved.getInt("targetZ").orElseThrow()) : pos; Map flags = new LinkedHashMap<>(); flags.put("allFixtureChunksReady", allChunks); flags.put("chunkLoaded", chunk != null); @@ -377,10 +377,10 @@ private void applyRoundTrip(RoundTripRequest request) { for (Fixture fixture : fixtures.values()) { if (fixture.to == null) continue; CompoundTag tag = WorksBlockData.save(request.bindings.get(fixture.id).tile); - if (!tag.contains("targetX", 99) || !tag.contains("targetY", 99) || !tag.contains("targetZ", 99) - || tag.getShort("progress") <= 0) throw new IllegalStateException("Drill must be mid-block " + fixture.id); - requireInside(fixture, new int[]{tag.getInt("targetX"), tag.getInt("targetY"), tag.getInt("targetZ")}); - if ("changed-target-read".equals(request.operation) && !tag.contains("TargetState", 10) && !tag.contains("TargetStates", 9)) + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "targetX", 99) || !zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "targetY", 99) || !zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "targetZ", 99) + || tag.getShort("progress").orElseThrow() <= 0) throw new IllegalStateException("Drill must be mid-block " + fixture.id); + requireInside(fixture, new int[]{tag.getInt("targetX").orElseThrow(), tag.getInt("targetY").orElseThrow(), tag.getInt("targetZ").orElseThrow()}); + if ("changed-target-read".equals(request.operation) && !zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "TargetState", 10) && !zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "TargetStates", 9)) throw new IllegalStateException("Changed-target prerequisite lacks restored saved state " + fixture.id); saved.put(fixture.id, tag); } @@ -396,11 +396,11 @@ private void applyRoundTrip(RoundTripRequest request) { if ("legacy-read".equals(request.operation)) { tag.remove("TargetState"); tag.remove("TargetStates"); } else { - request.world.setBlock(new BlockPos(tag.getInt("targetX"), tag.getInt("targetY"), tag.getInt("targetZ")), + request.world.setBlock(new BlockPos(tag.getInt("targetX").orElseThrow(), tag.getInt("targetY").orElseThrow(), tag.getInt("targetZ").orElseThrow()), Blocks.BEDROCK.defaultBlockState(), 3); } BlockEntity tile = request.bindings.get(entry.getKey()).tile; - tile.loadWithComponents(tag, tile.getLevel().registryAccess()); tile.setChanged(); + WorksBlockData.load(tile, tag); tile.setChanged(); } requireNewResult(request.label + "-initial"); Map initial = observe(request.world); @@ -434,7 +434,7 @@ private void finishRoundTrip(RoundTripRequest request) { Map result = observe(request.world); Map proof = new LinkedHashMap<>(); proof.put("protocol", LifecycleRoundTrip.PROTOCOL); proof.put("operation", request.operation); - proof.put("label", request.label); proof.put("dimension", request.world.dimension().location().toString()); + proof.put("label", request.label); proof.put("dimension", request.world.dimension().identifier().toString()); proof.put("status", request.window.failure == null ? "PASS" : "FAIL"); proof.put("failure", request.window.failure); proof.put("startPhase", "START"); proof.put("endPhase", request.completionPhase); proof.put("startWorldTime", request.window.startWorldTime); proof.put("endWorldTime", request.world.getGameTime()); @@ -466,16 +466,16 @@ private void finishRoundTrip(RoundTripRequest request) { System.out.println("ADVLIFECYCLE COMPLETE " + request.operation + " " + request.label); } - private void roundTripTick(TickEvent.LevelTickEvent event) { + private void roundTripTick(NativeTickEvents.LevelTickEvent event) { RoundTripRequest request = roundTrip; - if (request == null || event.level != request.world) return; + if (request == null || event.level() != request.world) return; boolean finish = false; try { - if (event.phase == TickEvent.Phase.START && !request.window.started) { + if (event.phase() == NativeTickEvents.Phase.START && !request.window.started) { if (request.window.start(request.world.getGameTime(), System.nanoTime(), roundTripPrerequisites(request))) applyRoundTrip(request); finish = request.window.finished; - } else if (event.phase == TickEvent.Phase.END && request.window.started) { + } else if (event.phase() == NativeTickEvents.Phase.END && request.window.started) { boolean complete = request.bindings.values().stream().allMatch(binding -> binding.capture.complete() && binding.current(request.world)); request.window.end(request.world.getGameTime(), complete); finish = true; @@ -485,7 +485,7 @@ private void roundTripTick(TickEvent.LevelTickEvent event) { finish = true; } if (finish) { - request.completionPhase = event.phase.name(); + request.completionPhase = event.phase().name(); finishRoundTrip(request); } } @@ -557,16 +557,16 @@ private Map witness(ServerLevel world, int[] coordinates) { return result; } - private void tick(TickEvent.LevelTickEvent event) { + private void tick(NativeTickEvents.LevelTickEvent event) { roundTripTick(event); - if (!watching || !(event.level instanceof ServerLevel)) return; - ServerLevel world = (ServerLevel) event.level; + if (!watching || !(event.level() instanceof ServerLevel)) return; + ServerLevel world = (ServerLevel) event.level(); for (Fixture fixture : fixtures.values()) { if (fixture.from == null || completed.contains(fixture.id)) continue; int step = routeSteps.getOrDefault(fixture.id, 0); int[] from = fixture.route == null ? fixture.from : fixture.route.get(step); int[] to = fixture.route == null ? fixture.to : fixture.route.get(step + 1); - if (event.phase == TickEvent.Phase.START) { + if (event.phase() == NativeTickEvents.Phase.START) { Map original = witness(world, from); if (fixture.block.equals(original.get("block"))) before.put(fixture.id, original); } else { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java index caa3e842..287bf250 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java @@ -21,7 +21,7 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.world.level.block.state.properties.Property; import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; import net.minecraftforge.registries.ForgeRegistries; @@ -92,7 +92,7 @@ void requireTickingFoundation() { } void block(int x, int y, int z, String id) { - ResourceLocation name = ResourceLocation.parse(id); + Identifier name = Identifier.parse(id); if (!ForgeRegistries.BLOCKS.containsKey(name)) throw new IllegalArgumentException("Missing block " + id); mutation("block", at(x, y, z), id); world.setBlock(at(x, y, z), ForgeRegistries.BLOCKS.getValue(name).defaultBlockState(), 3); @@ -128,7 +128,7 @@ Container inventory(int x, int y, int z) { } void put(int x, int y, int z, int slot, String item, int count) { - ResourceLocation id = ResourceLocation.parse(item); + Identifier id = Identifier.parse(item); if (!ForgeRegistries.ITEMS.containsKey(id) || count < 0 || count > 64) throw new IllegalArgumentException("Invalid stock " + item); mutation("inventory", at(x, y, z), slot + ":" + item + ":" + count); Container inventory = inventory(x, y, z); @@ -138,8 +138,8 @@ void put(int x, int y, int z, int slot, String item, int count) { } int count(int x, int y, int z, String item) { - Item expected = ForgeRegistries.ITEMS.getValue(ResourceLocation.parse(item)); - if (!ForgeRegistries.ITEMS.containsKey(ResourceLocation.parse(item))) throw new IllegalArgumentException("Missing item " + item); + Item expected = ForgeRegistries.ITEMS.getValue(Identifier.parse(item)); + if (!ForgeRegistries.ITEMS.containsKey(Identifier.parse(item))) throw new IllegalArgumentException("Missing item " + item); Container inventory = inventory(x, y, z); int total = 0; for (int slot = 0; slot < inventory.getContainerSize(); slot++) { @@ -180,7 +180,7 @@ void electricSupply(int x, int y, int z, float reserve) { } void tank(int x, int y, int z, String fluid, int amount) { - ResourceLocation id = ResourceLocation.parse(fluid); + Identifier id = Identifier.parse(fluid); if (!ForgeRegistries.FLUIDS.containsKey(id) || amount < 0) throw new IllegalArgumentException("Invalid fluid reserve"); mutation("tank", at(x, y, z), fluid + ":" + amount); cyano.poweradvantage.api.fluid.FluidTank tank = ((TileEntitySimpleFluidMachine) tile(x, y, z)).getTank(); @@ -202,7 +202,7 @@ void seed(int x, int y, int z, CompoundTag patch) { BlockEntity tile = tile(x, y, z); CompoundTag nbt = WorksBlockData.save(tile); nbt.merge(patch.copy()); - tile.loadWithComponents(nbt, world.registryAccess()); + WorksBlockData.load(tile, nbt); tile.setChanged(); } @@ -245,8 +245,7 @@ JsonArray entities() { net.minecraft.world.phys.AABB bounds = WorksBlockData.bounds(at(0, -1, 0), at(31, 31, 31)).inflate(.5); for (net.minecraft.world.entity.Entity entity : world.getEntitiesOfClass(net.minecraft.world.entity.Entity.class, bounds)) { if (entity instanceof net.minecraft.world.entity.player.Player) continue; - CompoundTag nbt = new CompoundTag(); - if (!entity.save(nbt)) throw new IllegalStateException("Test actor could not be saved: " + entity); + CompoundTag nbt = NativeActorIo.save(entity); JsonObject row = new JsonObject(); row.addProperty("uuid", entity.getUUID().toString()); row.addProperty("nbt", nbt.toString()); result.add(row); diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java index 946aff6f..5cd0d962 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java @@ -19,11 +19,11 @@ import net.minecraft.world.entity.Entity; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.core.BlockPos; -import net.minecraft.world.level.GameRules; +import net.minecraft.world.level.gamerules.GameRules; import net.minecraft.server.level.ServerLevel; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.RegisterCommandsEvent; -import net.minecraftforge.event.TickEvent; + /** Natural world ticks and recorded finite resources; never drives tickUpdate directly. */ final class MachineOperationalProbe { @@ -48,10 +48,10 @@ final class MachineOperationalProbe { static void register() { if (!MARKER.isFile()) return; MachineOperationalProbe probe = new MachineOperationalProbe(); - MinecraftForge.EVENT_BUS.addListener(probe::commands); - MinecraftForge.EVENT_BUS.addListener(probe::tick); - MinecraftForge.EVENT_BUS.addListener(probe::chunkLoaded); - MinecraftForge.EVENT_BUS.addListener(probe::entityJoining); + RegisterCommandsEvent.BUS.addListener(probe::commands); + NativeTickEvents.listenLevel(probe::tick); + net.minecraftforge.event.level.ChunkEvent.Load.BUS.addListener(probe::chunkLoaded); + net.minecraftforge.event.entity.EntityJoinLevelEvent.BUS.addListener(probe::entityJoining); } private void chunkLoaded(net.minecraftforge.event.level.ChunkEvent.Load event) { @@ -72,7 +72,7 @@ private void entityJoining(net.minecraftforge.event.entity.EntityJoinLevelEvent } private void commands(RegisterCommandsEvent event) { - event.getDispatcher().register(Commands.literal("advmachines").requires(source -> source.hasPermission(4)) + event.getDispatcher().register(Commands.literal("advmachines").requires(source -> source.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_OWNER)) .then(Commands.literal("run").then(Commands.argument("group", StringArgumentType.word()) .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { start(context.getSource().getLevel(), StringArgumentType.getString(context, "group"), @@ -152,14 +152,14 @@ private void setup(Run run) { } catch (Throwable failure) { fail(run, "setup", failure); } } - private void tick(TickEvent.LevelTickEvent event) { - if (event.level.isClientSide || !(event.level instanceof ServerLevel) - || event.level.dimension() != net.minecraft.world.level.Level.OVERWORLD) return; - ServerLevel current = (ServerLevel) event.level; - if (event.phase == TickEvent.Phase.START && paused) { + private void tick(NativeTickEvents.LevelTickEvent event) { + if (event.level().isClientSide() || !(event.level() instanceof ServerLevel) + || event.level().dimension() != net.minecraft.world.level.Level.OVERWORLD) return; + ServerLevel current = (ServerLevel) event.level(); + if (event.phase() == NativeTickEvents.Phase.START && paused) { held.addAll(NativeBlockEntityTicks.drain(current)); for (Entity entity : current.getAllEntities()) if (!(entity instanceof net.minecraft.world.entity.player.Player)) heldEntities.hold(entity); - } else if (event.phase == TickEvent.Phase.END) { + } else if (event.phase() == NativeTickEvents.Phase.END) { NativeBlockEntityTicks.restore(current, held); heldEntities.restore(); if (current != world) return; if (inspectionCases != null) { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/MigrationProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MigrationProbe.java index 6db36029..cfef1ab4 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/MigrationProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MigrationProbe.java @@ -19,7 +19,7 @@ import net.minecraft.core.BlockPos; import net.minecraft.world.level.Level; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.event.TickEvent; + import net.minecraftforge.event.level.LevelEvent; import net.minecraftforge.event.RegisterCommandsEvent; @@ -31,28 +31,28 @@ final class MigrationProbe { static void register() { if (!MARKER.isFile()) return; MigrationProbe probe = new MigrationProbe(); - MinecraftForge.EVENT_BUS.addListener(probe::load); - MinecraftForge.EVENT_BUS.addListener(probe::tick); - MinecraftForge.EVENT_BUS.addListener(probe::chunkLoaded); - MinecraftForge.EVENT_BUS.addListener(probe::starting); + LevelEvent.Load.BUS.addListener(probe::load); + NativeTickEvents.listenLevel(probe::tick); + net.minecraftforge.event.level.ChunkEvent.Load.BUS.addListener(probe::chunkLoaded); + RegisterCommandsEvent.BUS.addListener(probe::starting); } private void load(LevelEvent.Load event) { if (event.getLevel() instanceof Level) freeze((Level) event.getLevel()); } - private void tick(TickEvent.LevelTickEvent event) { - if (event.phase == TickEvent.Phase.START) freeze(event.level); + private void tick(NativeTickEvents.LevelTickEvent event) { + if (event.phase() == NativeTickEvents.Phase.START) freeze(event.level()); } private void chunkLoaded(net.minecraftforge.event.level.ChunkEvent.Load event) { if (event.getLevel() instanceof Level) freeze((Level) event.getLevel()); } private void freeze(Level world) { - if (!world.isClientSide && !new File("migration-operate.allow").isFile()) NativeBlockEntityTicks.drain(world); + if (!world.isClientSide() && !new File("migration-operate.allow").isFile()) NativeBlockEntityTicks.drain(world); } private void starting(RegisterCommandsEvent event) { event.getDispatcher().register(Commands.literal("advprobe") - .requires(source -> source.hasPermission(4)) + .requires(source -> source.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_OWNER)) .then(Commands.literal("snapshot") .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { try { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117EntityContext.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117EntityContext.java index 88dd2816..9c806c08 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117EntityContext.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117EntityContext.java @@ -16,7 +16,7 @@ static JsonObject observe(Entity entity, ServerLevel world) { Set seen = Collections.newSetFromMap(new IdentityHashMap()); collect(entity, world, worlds, seen); JsonObject result = new JsonObject(); - result.addProperty("worldDimensionKey", entity.level().dimension().location().toString()); + result.addProperty("worldDimensionKey", entity.level().dimension().identifier().toString()); result.addProperty("sameWorld", true); result.add("entityWorlds", worlds); return result; @@ -27,7 +27,7 @@ private static void collect(Entity entity, ServerLevel world, JsonObject worlds, if (!seen.add(entity)) throw new IllegalStateException("Repeated entity in dimension observation"); String uuid = entity.getUUID().toString(); if (worlds.has(uuid)) throw new IllegalStateException("Duplicate UUID in dimension observation"); - worlds.addProperty(uuid, entity.level().dimension().location().toString()); + worlds.addProperty(uuid, entity.level().dimension().identifier().toString()); for (Entity passenger : entity.getPassengers()) collect(passenger, world, worlds, seen); } } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117PlayerIo.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117PlayerIo.java index c6af69d7..824572f9 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117PlayerIo.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117PlayerIo.java @@ -1,30 +1,66 @@ package zone.moddev.mc.advantageworks; import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.io.IOException; +import java.nio.file.Path; +import java.util.UUID; +import net.minecraft.core.UUIDUtil; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.players.PlayerList; +import net.minecraft.world.level.Level; import net.minecraft.world.level.storage.PlayerDataStorage; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.world.level.storage.ValueInput; import net.minecraftforge.fml.util.ObfuscationReflectionHelper; final class Native117PlayerIo { private static final Method SAVE = findSave(); - private static final Field PLAYER_DATA = findPlayerData(); private Native117PlayerIo() {} + static CompoundTag load(PlayerList players, ServerPlayer player) { + CompoundTag saved = players.loadPlayerData(player.nameAndId()).orElseThrow( + () -> new IllegalStateException("No player file loaded")); + ValueInput input = TagValueInput.create(NativeActorIo.reporter("player " + player.getUUID()), + player.registryAccess(), saved); + UUID uuid = input.read("UUID", UUIDUtil.CODEC).orElseThrow(); + if (!uuid.equals(player.getUUID())) throw new IllegalStateException("Saved player UUID differs from requested player"); + var dimension = input.read("Dimension", Level.RESOURCE_KEY_CODEC).orElseThrow(); + ServerLevel world = player.level().getServer().getLevel(dimension); + if (world == null) throw new IllegalStateException("Saved player dimension unavailable: " + dimension.identifier()); + player.setServerLevel(world); + NativeActorIo.load(player, saved); + if (!uuid.equals(player.getUUID()) || player.level() != world) { + throw new IllegalStateException("Native player load changed identity or dimension"); + } + net.minecraftforge.event.ForgeEventFactory.firePlayerLoadingEvent(player, + players.getPlayerIo().getPlayerDataFolder(), uuid.toString()); + return saved; + } + static void save(PlayerList players, ServerPlayer player) { try { // Forge skips PlayerList.save for these offline probe players. if (player.connection == null) { - PlayerDataStorage data = (PlayerDataStorage) PLAYER_DATA.get(players); + PlayerDataStorage data = players.getPlayerIo(); if (data == null) throw new IllegalStateException("Native PlayerData is missing"); + CompoundTag expected = NativeActorIo.saveWithoutId(player); data.save(player); + // Native storage logs I/O failures instead of throwing; require the actual saved state. + Path file = data.getPlayerDataFolder().toPath().resolve(player.getStringUUID() + ".dat"); + if (!expected.equals(NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap()))) { + throw new IllegalStateException("Native player save did not persist the observed state"); + } } else { + NativeActorIo.saveWithoutId(player); SAVE.invoke(players, player); } - } catch (IllegalAccessException | InvocationTargetException error) { + } catch (IllegalAccessException | InvocationTargetException | IOException error) { throw new IllegalStateException("Native player save failed", error); } } @@ -39,13 +75,4 @@ private static Method findSave() { } } - private static Field findPlayerData() { - try { - Field field = PlayerList.class.getDeclaredField("playerIo"); - field.setAccessible(true); - return field; - } catch (NoSuchFieldException officialNameAbsent) { - return ObfuscationReflectionHelper.findField(PlayerList.class, "f_11204_"); - } - } } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117Readiness.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117Readiness.java index c4538947..83c7abca 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117Readiness.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117Readiness.java @@ -17,7 +17,7 @@ import net.minecraft.world.level.ChunkPos; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.RegisterCommandsEvent; -import net.minecraftforge.event.TickEvent; + /** Observes native chunk readiness; never loads chunks, constructs actors or changes world state. */ final class Native117Readiness { @@ -34,12 +34,12 @@ final class Native117Readiness { static void register() { if (!MARKER.isFile()) return; Native117Readiness probe = new Native117Readiness(); - MinecraftForge.EVENT_BUS.addListener(probe::commands); - MinecraftForge.EVENT_BUS.addListener(probe::tick); + RegisterCommandsEvent.BUS.addListener(probe::commands); + NativeTickEvents.listenLevel(probe::tick); } private void commands(RegisterCommandsEvent event) { - event.getDispatcher().register(Commands.literal("advreadiness").requires(source -> source.hasPermission(4)) + event.getDispatcher().register(Commands.literal("advreadiness").requires(source -> source.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_OWNER)) .then(Commands.literal("chunks") .then(Commands.argument("minX", IntegerArgumentType.integer(-1875000, 1874999)) .then(Commands.argument("minZ", IntegerArgumentType.integer(-1875000, 1874999)) @@ -69,12 +69,12 @@ private void request(ServerLevel level, Bounds bounds, String requestedLabel) { System.out.println("ADVREADINESS QUEUED " + label); } - private void tick(TickEvent.LevelTickEvent event) { - if (observation == null || event.phase != TickEvent.Phase.END || event.level != world) return; + private void tick(NativeTickEvents.LevelTickEvent event) { + if (observation == null || event.phase() != NativeTickEvents.Phase.END || event.level() != world) return; List states = new ArrayList<>(); for (int x = observation.bounds.minX; x <= observation.bounds.maxX; x++) { for (int z = observation.bounds.minZ; z <= observation.bounds.maxZ; z++) { - BlockPos pos = new BlockPos(x * 16, world.getMinBuildHeight(), z * 16); + BlockPos pos = new BlockPos(x * 16, world.getMinY(), z * 16); states.add(new State(x, z, Native118ChunkTicking.withEntitiesLoaded(world, pos), world.isPositionEntityTicking(pos))); } @@ -82,8 +82,8 @@ private void tick(TickEvent.LevelTickEvent event) { if (!observation.observe(world.getGameTime(), System.nanoTime(), states)) return; JsonObject report = observation.report(); report.addProperty("label", label); - report.addProperty("minecraft", net.minecraft.SharedConstants.getCurrentVersion().getName()); - report.addProperty("dimension", world.dimension().location().toString()); + report.addProperty("minecraft", net.minecraft.SharedConstants.getCurrentVersion().name()); + report.addProperty("dimension", world.dimension().identifier().toString()); try { Files.createDirectories(output.toPath().getParent()); Files.writeString(output.toPath(), new GsonBuilder().setPrettyPrinting().create().toJson(report), diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshot.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshot.java index 003584e5..e69fe6f2 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshot.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshot.java @@ -20,6 +20,7 @@ import java.util.UUID; import java.util.WeakHashMap; import net.minecraft.SharedConstants; +import net.minecraft.core.UUIDUtil; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtIo; import net.minecraft.resources.ResourceKey; @@ -31,7 +32,7 @@ import net.minecraft.world.level.Level; import net.minecraft.world.level.storage.LevelResource; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.listener.Priority; import net.minecraftforge.event.server.ServerStartingEvent; import net.minecraftforge.event.server.ServerStoppingEvent; import org.apache.logging.log4j.LogManager; @@ -59,8 +60,8 @@ static boolean marked(Path runtime, String name) { private static JsonObject header(MinecraftServer server, String boot, String phase) { JsonObject result = new JsonObject(); result.addProperty("format", 1); - result.addProperty("minecraftVersion", SharedConstants.getCurrentVersion().getName()); - result.addProperty("dataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + result.addProperty("minecraftVersion", SharedConstants.getCurrentVersion().name()); + result.addProperty("dataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); result.addProperty("worldName", server.getWorldData().getLevelName()); result.addProperty("worldDirectory", server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString()); result.addProperty("bootId", boot); @@ -97,15 +98,15 @@ private static void stopping(ServerStoppingEvent event) { for (int id : new int[] {0, -1, 1}) { ResourceKey key = Native117Worlds.key(id); ServerLevel world = server.getLevel(key); - if (world == null) throw new IllegalStateException("Missing vanilla dimension: " + key.location()); + if (world == null) throw new IllegalStateException("Missing vanilla dimension: " + key.identifier()); JsonArray entities = entities(world, world.getAllEntities(), seen); JsonObject dimension = new JsonObject(); dimension.addProperty("dimension", id); - dimension.addProperty("worldDimensionKey", key.location().toString()); + dimension.addProperty("worldDimensionKey", key.identifier().toString()); dimension.addProperty("gameTime", world.getGameTime()); dimension.addProperty("entityCount", entities.size()); dimension.add("entities", entities); - dimensions.add(key.location().toString(), dimension); + dimensions.add(key.identifier().toString(), dimension); count += entities.size(); } result.add("dimensions", dimensions); @@ -133,15 +134,15 @@ static JsonArray entities(ServerLevel world, Iterable available, Set BOOTS = Collections.synchronizedMap(new WeakHashMap<>()); private Native117StoppedEntitiesSnapshotV2() {} static void register() { - MinecraftForge.EVENT_BUS.addListener(EventPriority.LOWEST, Native117StoppedEntitiesSnapshotV2::starting); - MinecraftForge.EVENT_BUS.addListener(EventPriority.LOWEST, Native117StoppedEntitiesSnapshotV2::stopping); + ServerStartingEvent.BUS.addListener(Priority.LOWEST, Native117StoppedEntitiesSnapshotV2::starting); + ServerStoppingEvent.BUS.addListener(Priority.LOWEST, Native117StoppedEntitiesSnapshotV2::stopping); } static byte[] contract() throws IOException { try (InputStream input = Native117StoppedEntitiesSnapshotV2.class.getResourceAsStream( "/assets/advantageworkstest/" + CONTRACT)) { if (input == null) throw new IOException("Missing native helper control evidence"); - return input.readAllBytes(); + byte[] bytes = input.readAllBytes(); + validateContract(bytes); + return bytes; + } + } + + static void validateContract(byte[] bytes) throws IOException { + try { + JsonObject control = JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + if (control.get("format").getAsInt() != 1 || !POLICY.equals(control.get("policy").getAsString()) + || !SharedConstants.getCurrentVersion().name().equals(control.get("minecraftVersion").getAsString()) + || SharedConstants.getCurrentVersion().dataVersion().version() != control.get("dataVersion").getAsInt() + || !"61.1.0".equals(control.get("forgeVersion").getAsString())) { + throw new IOException("Native helper control does not describe this pinned engine"); + } + } catch (RuntimeException error) { + throw new IOException("Invalid native helper control", error); } } @@ -63,8 +81,8 @@ static String sha(byte[] bytes) throws Exception { private static JsonObject header(MinecraftServer server, String boot, String phase) throws Exception { JsonObject result = new JsonObject(); result.addProperty("format", 2); - result.addProperty("minecraftVersion", SharedConstants.getCurrentVersion().getName()); - result.addProperty("dataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + result.addProperty("minecraftVersion", SharedConstants.getCurrentVersion().name()); + result.addProperty("dataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); result.addProperty("worldName", server.getWorldData().getLevelName()); result.addProperty("worldDirectory", server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString()); result.addProperty("bootId", boot); @@ -107,7 +125,7 @@ private static void stopping(ServerStoppingEvent event) { if (world == null) throw new IllegalStateException("Missing vanilla dimension: " + id); JsonObject dimension = partition(world, world.getAllEntities(), seen); dimension.addProperty("gameTime", world.getGameTime()); - dimensions.add(world.dimension().location().toString(), dimension); + dimensions.add(world.dimension().identifier().toString(), dimension); persistent += dimension.get("entityCount").getAsInt(); helpers += dimension.get("nonpersistentHelperCount").getAsInt(); } @@ -144,9 +162,9 @@ static JsonObject partition(ServerLevel world, Iterable available, Set available, Set available, Set TICKET = TicketType.create( - "advantageworks_worldgen_sample", Comparator.comparingLong(ChunkPos::toLong)); + private static final TicketType TICKET = new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING); private final ServerLevel world; private final BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); private final AsyncWorldgenChunks chunks; @@ -28,7 +27,7 @@ final class Native117WorldgenAccess implements WorldgenSamplingJob.WorldAccess { chunks = new AsyncWorldgenChunks<>(new AsyncWorldgenChunks.Backend() { @Override public void retain(int x, int z) { ChunkPos position = new ChunkPos(x, z); - source.addRegionTicket(TICKET, position, 0, position); + source.addTicketWithRadius(TICKET, position, 0); } @Override public CompletableFuture request(int x, int z) { @@ -46,7 +45,7 @@ final class Native117WorldgenAccess implements WorldgenSamplingJob.WorldAccess { @Override public void release(int x, int z) { ChunkPos position = new ChunkPos(x, z); - source.removeRegionTicket(TICKET, position, 0, position); + source.removeTicketWithRadius(TICKET, position, 0); } }, Executors.newSingleThreadExecutor(task -> { Thread thread = new Thread(task, "advantageworks-worldgen-dispatch"); @@ -55,7 +54,7 @@ final class Native117WorldgenAccess implements WorldgenSamplingJob.WorldAccess { })); } - @Override public int minBuildHeight() { return world.getMinBuildHeight(); } + @Override public int minBuildHeight() { return world.getMinY(); } @Override public boolean matchesBiome(String expected, int x, int y, int z) { return WorksController.matchesExpectedBiome(expected, world.getBiome(cursor.set(x, y, z))); @@ -71,7 +70,7 @@ final class Native117WorldgenAccess implements WorldgenSamplingJob.WorldAccess { } @Override public String blockName(int blockX, int y, int blockZ) { - ResourceLocation id = ForgeRegistries.BLOCKS.getKey( + Identifier id = ForgeRegistries.BLOCKS.getKey( chunk.getBlockState(cursor.set(blockX, y, blockZ)).getBlock()); return id == null ? null : id.toString(); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117Worlds.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117Worlds.java index 69a04091..937a43fe 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117Worlds.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/Native117Worlds.java @@ -23,7 +23,7 @@ static int legacyId(ResourceKey key) { if (key.equals(Level.NETHER)) return -1; if (key.equals(Level.OVERWORLD)) return 0; if (key.equals(Level.END)) return 1; - throw new IllegalArgumentException("Unsupported native fixture dimension: " + key.location()); + throw new IllegalArgumentException("Unsupported native fixture dimension: " + key.identifier()); } static Stream entities(ServerLevel world) { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeActorIo.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeActorIo.java new file mode 100644 index 00000000..12fced72 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeActorIo.java @@ -0,0 +1,50 @@ +package zone.moddev.mc.advantageworks; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.world.level.storage.TagValueOutput; + +/** Native registry-aware persistence; partial codec results are never witnesses. */ +final class NativeActorIo { + private NativeActorIo() {} + + static CompoundTag save(Entity entity) { + TagValueOutput output = output(entity); + if (!entity.save(output)) throw new IllegalStateException("Native entity save refused " + entity.getUUID()); + return output.buildResult(); + } + + static CompoundTag saveWithoutId(Entity entity) { + TagValueOutput output = output(entity); + entity.saveWithoutId(output); + return output.buildResult(); + } + + static CompoundTag saveAsPassenger(Entity entity) { + TagValueOutput output = output(entity); + if (!entity.saveAsPassenger(output)) throw new IllegalStateException("Native passenger save refused " + entity.getUUID()); + return output.buildResult(); + } + + static void load(Entity entity, CompoundTag nbt) { + entity.load(TagValueInput.create(reporter("load " + entity.getUUID()), entity.registryAccess(), nbt)); + } + + private static TagValueOutput output(Entity entity) { + return TagValueOutput.createWithContext(reporter("save " + entity.getUUID()), entity.registryAccess()); + } + + static ProblemReporter reporter(String path) { + return new ProblemReporter() { + @Override public ProblemReporter forChild(ProblemReporter.PathElement child) { + return reporter(path + child.get()); + } + + @Override public void report(ProblemReporter.Problem problem) { + throw new IllegalStateException("Native actor persistence problem at " + path + ": " + problem.description()); + } + }; + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeEntityData.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeEntityData.java index f022be1e..1e73c9a2 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeEntityData.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeEntityData.java @@ -14,12 +14,12 @@ private NativeEntityData() { } static boolean containsString(Entity entity, String key) { CompoundTag data = peek(entity); - return data != null && data.contains(key, 8); + return data != null && zone.moddev.mc.basepower.util.NbtCompat.containsTag(data, key, 8); } static String string(Entity entity, String key) { CompoundTag data = peek(entity); - return data == null ? "" : data.getString(key); + return data == null ? "" : data.getString(key).orElse(""); } private static CompoundTag peek(Entity entity) { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java index fbf59024..e66d99aa 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java @@ -27,7 +27,7 @@ import net.minecraft.world.level.block.state.properties.BooleanProperty; import net.minecraft.world.InteractionHand; import net.minecraft.core.Direction; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.world.phys.AABB; import net.minecraft.core.BlockPos; import net.minecraft.world.phys.shapes.BooleanOp; @@ -44,12 +44,12 @@ final class NativeHarvestProbe { private static final BlockPos POS = new BlockPos(1200, 8, 1024); static void register() { - if (MARKER.isFile()) MinecraftForge.EVENT_BUS.addListener(new NativeHarvestProbe()::starting); + if (MARKER.isFile()) RegisterCommandsEvent.BUS.addListener(new NativeHarvestProbe()::starting); } private void starting(RegisterCommandsEvent event) { event.getDispatcher().register(Commands.literal("advharvest") - .requires(source -> source.hasPermission(4)) + .requires(source -> source.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_OWNER)) .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { WorksController.getInstance().requireAuthorizedWorld(context.getSource().getServer()); try { run(context.getSource().getLevel(), StringArgumentType.getString(context, "label")); } @@ -77,7 +77,7 @@ private static void witness(ServerLevel world, ServerPlayer player, JsonArray ro JsonArray dropEvidence = new JsonArray(); row.add("dropEvidence", dropEvidence); try { - ResourceLocation key = ResourceLocation.parse(blockId); + Identifier key = Identifier.parse(blockId); require(ForgeRegistries.BLOCKS.containsKey(key), "Missing real block: " + blockId); Block block = ForgeRegistries.BLOCKS.getValue(key); player.gameMode.changeGameModeForPlayer(GameType.SURVIVAL); @@ -204,7 +204,7 @@ private static void run(ServerLevel world, String label) throws Exception { JsonObject solar = new JsonObject(); solar.addProperty("id", "solar/native-solidity"); try { - Block block = ForgeRegistries.BLOCKS.getValue(ResourceLocation.parse("baseelectric:photovoltaic_generator")); + Block block = ForgeRegistries.BLOCKS.getValue(Identifier.parse("baseelectric:photovoltaic_generator")); require(block != null && block != net.minecraft.world.level.block.Blocks.AIR, "Missing native photovoltaic block"); world.setBlock(POS, block.defaultBlockState(), 2); require(world.getBlockState(POS).canOcclude(), "Legacy photovoltaic solid=true was lost"); @@ -219,7 +219,7 @@ private static void run(ServerLevel world, String label) throws Exception { harvestDrops(world, new AABB(POS).inflate(2)).forEach(ItemEntity::discard); JsonObject report = new JsonObject(); report.addProperty("label", label); - report.addProperty("minecraft", net.minecraft.SharedConstants.getCurrentVersion().getName()); + report.addProperty("minecraft", net.minecraft.SharedConstants.getCurrentVersion().name()); report.addProperty("worldTime", world.getGameTime()); report.addProperty("fixtureIsolation", "native117-live-drops-v1"); report.addProperty("status", java.util.stream.StreamSupport.stream(rows.spliterator(), false) diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeInteropProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeInteropProbe.java index b34d5ee1..6e23e516 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeInteropProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeInteropProbe.java @@ -19,7 +19,7 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.core.Direction; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.world.phys.AABB; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; @@ -43,12 +43,12 @@ final class NativeInteropProbe { private static final BlockPos BELT = new BlockPos(1040, 8, 1024); static void register() { - if (MARKER.isFile()) MinecraftForge.EVENT_BUS.addListener(new NativeInteropProbe()::starting); + if (MARKER.isFile()) RegisterCommandsEvent.BUS.addListener(new NativeInteropProbe()::starting); } private void starting(RegisterCommandsEvent event) { event.getDispatcher().register(Commands.literal("advinterop") - .requires(source -> source.hasPermission(4)) + .requires(source -> source.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_OWNER)) .then(Commands.argument("phase", StringArgumentType.word()).executes(context -> { String phase = StringArgumentType.getString(context, "phase"); try { run(context.getSource().getLevel(), phase); } @@ -77,7 +77,7 @@ private static void check(JsonArray checks, String id, Runnable body) { } private static Block block(String id) { - ResourceLocation key = ResourceLocation.parse(id); + Identifier key = Identifier.parse(id); require(ForgeRegistries.BLOCKS.containsKey(key), "Missing registry entry: " + id); return ForgeRegistries.BLOCKS.getValue(key); } @@ -180,8 +180,8 @@ private static void tank(ServerLevel world, JsonArray checks) { require(drops.size() == 1, "Expected exactly one portable tank drop; got " + drops.size()); ItemStack item = drops.get(0).getItem().copy(); require(item.getItem() == block.asItem() && item.getCount() == 1 - && WorksItemData.read(item).getString("FluidID").equals("minecraft:water") - && WorksItemData.read(item).getInt("Volume") == 500, "Native loot lost portable fluid NBT: " + item); + && WorksItemData.read(item).getString("FluidID").orElseThrow().equals("minecraft:water") + && WorksItemData.read(item).getInt("Volume").orElseThrow() == 500, "Native loot lost portable fluid NBT: " + item); drops.get(0).discard(); world.setBlock(TANK, block.defaultBlockState(), 3); block.setPlacedBy(world, TANK, block.defaultBlockState(), @@ -201,16 +201,23 @@ private static void locks(ServerLevel world, JsonArray checks) { TileEntityConveyor belt = (TileEntityConveyor) world.getBlockEntity(BELT); BlockEntity chest = world.getBlockEntity(BELT.west()); CompoundTag state = WorksBlockData.save(chest); - state.putString("Lock", "AdvantageInteropKey"); - chest.loadWithComponents(state, world.registryAccess()); + net.minecraft.world.LockCode lock = new net.minecraft.world.LockCode( + net.minecraft.advancements.criterion.ItemPredicate.Builder.item().of( + world.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.ITEM), + net.minecraft.world.item.Items.STICK).build()); + state.put("lock", net.minecraft.world.LockCode.CODEC.encodeStart( + world.registryAccess().createSerializationContext(net.minecraft.nbt.NbtOps.INSTANCE), lock).getOrThrow()); + WorksBlockData.load(chest, state); check(checks, "locked-chest/conveyor-refuses-extraction", () -> { + require(((net.minecraft.world.level.block.entity.BaseContainerBlockEntity) chest).isLocked(), + "Vanilla did not install the native chest lock"); require(InventoryLocks.isLocked(input), "Native nonempty chest lock was not detected"); for (int i = 0; i < 160; i++) belt.tick(); require(input.getItem(0).getCount() == 16 && output.isEmpty() && belt.isEmpty(), "Conveyor bypassed locked source"); }); - state.remove("Lock"); - chest.loadWithComponents(state, world.registryAccess()); + state.remove("lock"); + WorksBlockData.load(chest, state); check(checks, "unlocked-chest/conveyor-transfers-exactly", () -> { require(!InventoryLocks.isLocked(input), "Cleared lock still blocks extraction"); for (int i = 0; i < 160; i++) belt.tick(); @@ -276,7 +283,7 @@ private static void capabilityCallbacks(JsonArray checks) { IItemHandler oldItemWrapper = oldItems.orElseThrow(() -> new AssertionError("Missing original item wrapper")); FluidStack before = oldFluidWrapper.getFluidInTank(0); require(before.getFluid() == net.minecraft.world.level.material.Fluids.WATER && before.getAmount() == 500 - && before.hasTag() && before.getTag().getString("InteropWitness").equals("native117-callback") + && before.hasTag() && before.getTag().getString("InteropWitness").orElseThrow().equals("native117-callback") && oldItemWrapper.getSlots() == 0, "Standalone wrappers do not expose the seeded tank contents"); CompoundTag contents = tile.getTank().writeToNBT(new CompoundTag()); CompoundTag exposedFluid = before.writeToNBT(new CompoundTag()); @@ -342,17 +349,18 @@ private static void run(ServerLevel world, String phase) throws Exception { @SuppressWarnings("unchecked") private static void sharedCrushing(ServerLevel world) { - ResourceLocation recipeId = ResourceLocation.fromNamespaceAndPath("basepower", "crushing_potato_starch"); - var holder = world.getRecipeManager().byKey(recipeId).orElseThrow( + Identifier recipeId = Identifier.fromNamespaceAndPath("basepower", "crushing_potato_starch"); + var holder = world.getServer().getRecipeManager().byKey(net.minecraft.resources.ResourceKey.create( + net.minecraft.core.registries.Registries.RECIPE, recipeId)).orElseThrow( () -> new AssertionError("Missing Power recipe under Base Metals crushing: " + recipeId)); var recipe = (net.minecraft.world.item.crafting.Recipe) holder.value(); var type = net.minecraft.core.registries.BuiltInRegistries.RECIPE_TYPE.getKey(recipe.getType()); - require(ResourceLocation.fromNamespaceAndPath("basemetals", "crushing").equals(type), "Wrong loaded crushing type: " + type); + require(Identifier.fromNamespaceAndPath("basemetals", "crushing").equals(type), "Wrong loaded crushing type: " + type); for (var potato : List.of(net.minecraft.world.item.Items.POTATO, net.minecraft.world.item.Items.POISONOUS_POTATO)) { var input = new net.minecraft.world.item.crafting.SingleRecipeInput(new ItemStack(potato, 3)); require(recipe.matches(input, world), "Shared crusher rejected " + potato); ItemStack result = recipe.assemble(input, world.registryAccess()); - require(result.getCount() == 1 && ResourceLocation.fromNamespaceAndPath("basepower", "starch").equals( + require(result.getCount() == 1 && Identifier.fromNamespaceAndPath("basepower", "starch").equals( ForgeRegistries.ITEMS.getKey(result.getItem())), "Incorrect shared crushing result: " + result); require(input.getItem(0).getCount() == 3, "Assembling a recipe preview mutated its input"); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeOilBucketProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeOilBucketProbe.java index 54e6545c..e8e5ed51 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeOilBucketProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeOilBucketProbe.java @@ -11,7 +11,7 @@ import net.minecraft.commands.Commands; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.InteractionHand; import net.minecraft.world.MenuProvider; @@ -54,11 +54,11 @@ private static final class BucketActor extends WorksOfflinePlayer { } static void register() { - if (Files.isRegularFile(MARKER)) MinecraftForge.EVENT_BUS.addListener(NativeOilBucketProbe::commands); + if (Files.isRegularFile(MARKER)) RegisterCommandsEvent.BUS.addListener(NativeOilBucketProbe::commands); } private static void commands(RegisterCommandsEvent event) { - event.getDispatcher().register(Commands.literal("advoilbuckets").requires(source -> source.hasPermission(4)) + event.getDispatcher().register(Commands.literal("advoilbuckets").requires(source -> source.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_OWNER)) .executes(context -> { try { run(context.getSource().getLevel()); @@ -111,7 +111,7 @@ private static void worldRoundTrip(ServerLevel world, WorksOfflinePlayer player, for (Direction direction : Direction.Plane.HORIZONTAL) { world.setBlockAndUpdate(basin.relative(direction), Blocks.STONE.defaultBlockState()); } - player.moveTo(basin.getX() + 0.5, basin.getY() + 1.5, basin.getZ() + 0.5, 0, 90); + player.snapTo(basin.getX() + 0.5, basin.getY() + 1.5, basin.getZ() + 0.5, 0, 90); hand(player, filled); var placed = player.gameMode.useItem(player, world, player.getItemInHand(HAND), HAND); require(placed.consumesAction(), "Filled bucket world use returned " + placed); @@ -129,7 +129,7 @@ private static void worldRoundTrip(ServerLevel world, WorksOfflinePlayer player, private static IFluidHandler machine(ServerLevel world, BlockPos pos, Fluid oil, int amount) { world.setBlockAndUpdate(pos, Blocks.AIR.defaultBlockState()); - var block = ForgeRegistries.BLOCKS.getValue(ResourceLocation.fromNamespaceAndPath("basepower", "fluid_storage_tank")); + var block = ForgeRegistries.BLOCKS.getValue(Identifier.fromNamespaceAndPath("basepower", "fluid_storage_tank")); require(block != null, "Storage tank block unavailable"); world.setBlockAndUpdate(pos, block.defaultBlockState()); var tile = world.getBlockEntity(pos); @@ -150,7 +150,7 @@ private static void expectFluid(IFluidHandler tank, Fluid oil, int amount) { } private static boolean clickTank(ServerLevel world, WorksOfflinePlayer player, BlockPos pos) { - player.moveTo(pos.getX() + 0.5, pos.getY() + 1.5, pos.getZ() + 1.5, 180, 35); + player.snapTo(pos.getX() + 0.5, pos.getY() + 1.5, pos.getZ() + 1.5, 180, 35); var hit = new BlockHitResult(Vec3.atCenterOf(pos).add(0, 0.5, 0), Direction.UP, pos, false); return player.gameMode.useItemOn(player, world, player.getItemInHand(HAND), HAND, hit).consumesAction(); } @@ -196,8 +196,8 @@ private static void run(ServerLevel world) throws Exception { JsonArray checks = new JsonArray(); for (int index = 0; index < 2; index++) { String name = index == 0 ? "crude_oil" : "refined_oil"; - var oil = ForgeRegistries.FLUIDS.getValue(ResourceLocation.fromNamespaceAndPath("basepower", name)); - var filled = ForgeRegistries.ITEMS.getValue(ResourceLocation.fromNamespaceAndPath("basepower", name + "_bucket")); + var oil = ForgeRegistries.FLUIDS.getValue(Identifier.fromNamespaceAndPath("basepower", name)); + var filled = ForgeRegistries.ITEMS.getValue(Identifier.fromNamespaceAndPath("basepower", name + "_bucket")); require(oil != null && filled instanceof net.minecraft.world.item.BucketItem, "Oil source or bucket unavailable"); int x = 1072 + 16 * index; BlockPos basin = new BlockPos(x, 8, 1025), tank = new BlockPos(x, 8, 1029); diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeTickEvents.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeTickEvents.java new file mode 100644 index 00000000..e05e61f3 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeTickEvents.java @@ -0,0 +1,30 @@ +package zone.moddev.mc.advantageworks; + +import java.util.function.Consumer; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.Level; +import net.minecraftforge.event.TickEvent; + +/** Keeps each fixture's pre/post observation boundary explicit on Forge's split event buses. */ +final class NativeTickEvents { + enum Phase { START, END } + + record LevelTickEvent(Level level, Phase phase) {} + record ServerTickEvent(MinecraftServer server, Phase phase) {} + + private NativeTickEvents() {} + + static void listenLevel(Consumer observer) { + TickEvent.LevelTickEvent.Pre.BUS.addListener(event -> + observer.accept(new LevelTickEvent(event.level(), Phase.START))); + TickEvent.LevelTickEvent.Post.BUS.addListener(event -> + observer.accept(new LevelTickEvent(event.level(), Phase.END))); + } + + static void listenServer(Consumer observer) { + TickEvent.ServerTickEvent.Pre.BUS.addListener(event -> + observer.accept(new ServerTickEvent(event.server(), Phase.START))); + TickEvent.ServerTickEvent.Post.BUS.addListener(event -> + observer.accept(new ServerTickEvent(event.server(), Phase.END))); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeTicketGeometry.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeTicketGeometry.java index dc892fc7..1c9493ca 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeTicketGeometry.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeTicketGeometry.java @@ -28,16 +28,12 @@ static Map observe(ServerLevel world) { if (ChunkLevel.fullStatus(level) == FullChunkStatus.ENTITY_TICKING) entityTickingLevel = level; } - ServerChunkCache provider = world.getChunkSource(); - DistanceManager manager = ObfuscationReflectionHelper.getPrivateValue( - ServerChunkCache.class, provider, "f_8327_"); - // Forge 36 adds a second map, forcedTickets; observe the vanilla tickets map only. - Long2ObjectOpenHashMap tickets = ObfuscationReflectionHelper.getPrivateValue( - DistanceManager.class, manager, "f_140761_"); + net.minecraft.world.level.TicketStorage tickets = world.getDataStorage().get( + net.minecraft.world.level.TicketStorage.TYPE); + if (tickets == null) throw new IllegalStateException("Native chunk ticket storage is missing"); Set forcedLevels = new TreeSet<>(); - for (Object value : tickets.values()) { - for (Object entry : (Iterable) value) { - Ticket ticket = (Ticket) entry; + for (long chunk : tickets.getForceLoadedChunks()) { + for (Ticket ticket : tickets.getTickets(chunk)) { if (ticket.getType() == TicketType.FORCED) forcedLevels.add(ticket.getTicketLevel()); } } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/PowerOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/PowerOperationalCases.java index ebc87bac..7d53f6f6 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/PowerOperationalCases.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/PowerOperationalCases.java @@ -16,9 +16,15 @@ static void register(List cases) { "Automation must not bypass a vanilla chest lock, at either end of the belt", 160, c -> { conveyor(c, "basepower:item_conveyor"); c.put(4, 2, 5, 0, "minecraft:dirt", 16); - net.minecraft.nbt.CompoundTag lock = new net.minecraft.nbt.CompoundTag(); lock.putString("Lock", "Works key"); - c.seed(sourceLocked ? 4 : 6, 2, 5, lock); + int lockedX = sourceLocked ? 4 : 6; + c.seed(lockedX, 2, 5, nativeChestLock(c.world.registryAccess())); + c.require(((net.minecraft.world.level.block.entity.BaseContainerBlockEntity)c.tile(lockedX, 2, 5)).isLocked(), + "Vanilla installed the native lock before conveyor ticks"); + c.require(zone.moddev.mc.basepower.util.InventoryLocks.isLocked(c.inventory(lockedX, 2, 5)), + "Automation observes the installed native chest lock"); }, c -> { + c.require(((net.minecraft.world.level.block.entity.BaseContainerBlockEntity)c.tile(sourceLocked ? 4 : 6, 2, 5)).isLocked(), + "Conveyor leaves the native chest lock installed"); c.require(c.count(6, 2, 5, "minecraft:dirt") == 0, "locked route delivers no stock"); c.require(c.count(4, 2, 5, "minecraft:dirt") + c.count(5, 2, 5, "minecraft:dirt") == 16, "locked route retains every input item"); @@ -71,7 +77,7 @@ static void register(List cases) { for (int slot = 0; slot < c.inventory(6, 2, 5).getContainerSize(); slot++) { ItemStack stack = c.inventory(6, 2, 5).getItem(slot); if (stack.isEmpty()) continue; - String witness = WorksItemData.read(stack).getString("Witness"); + String witness = WorksItemData.read(stack).getString("Witness").orElseThrow(); if (witness.equals("A")) a += stack.getCount(); else if (witness.equals("B")) b += stack.getCount(); else unknown += stack.getCount(); @@ -124,7 +130,7 @@ static void register(List cases) { c -> { c.require(c.world.isEmptyBlock(c.at(5, 2, 5)), "native source collected"); c.require(c.fluidAmount(7, 1, 5) == 1000 && c.fluidAmount(5, 1, 5) == 0, "exact source volume delivered"); - c.require(c.nbt(7, 1, 5).getCompound("Tank").getString("FluidName").equals(fluid), "fluid registry identity retained"); + c.require(c.nbt(7, 1, 5).getCompound("Tank").orElseThrow().getString("FluidName").orElseThrow().equals(fluid), "fluid registry identity retained"); })); } cases.add(new MachineScenario("power/drain/non-source", "Flowing water without a source cannot create a bucket", 80, @@ -169,6 +175,16 @@ static void register(List cases) { })); } + static net.minecraft.nbt.CompoundTag nativeChestLock(net.minecraft.core.HolderLookup.Provider registries) { + net.minecraft.world.LockCode lock = new net.minecraft.world.LockCode( + net.minecraft.advancements.criterion.ItemPredicate.Builder.item().of( + registries.lookupOrThrow(net.minecraft.core.registries.Registries.ITEM), Items.STICK).build()); + net.minecraft.nbt.CompoundTag patch = new net.minecraft.nbt.CompoundTag(); + patch.put("lock", net.minecraft.world.LockCode.CODEC.encodeStart( + registries.createSerializationContext(net.minecraft.nbt.NbtOps.INSTANCE), lock).getOrThrow()); + return patch; + } + private static void stills(List cases) { for (String mode : new String[]{"production", "no-fuel", "invalid", "full", "one-space", "redstone"}) { final int output = mode.equals("full") ? 1000 : mode.equals("one-space") ? 999 : 0; @@ -181,7 +197,7 @@ private static void stills(List cases) { (zone.moddev.mc.basepower.machines.fluidmachines.StillTileEntity)c.tile(8, 4, 8); still.getInputTank().setFluid(new cyano.poweradvantage.api.fluid.FluidStack( mode.equals("invalid") ? net.minecraft.world.level.material.Fluids.WATER - : net.minecraftforge.registries.ForgeRegistries.FLUIDS.getValue(net.minecraft.resources.ResourceLocation.parse("basepower:crude_oil")), 1000)); + : net.minecraftforge.registries.ForgeRegistries.FLUIDS.getValue(net.minecraft.resources.Identifier.parse("basepower:crude_oil")), 1000)); if (!mode.equals("no-fuel")) c.put(8, 4, 8, 0, "minecraft:coal", 1); if (output > 0) c.tank(8, 4, 8, "basepower:refined_oil", output); if (mode.equals("redstone")) c.block(9, 4, 8, "minecraft:redstone_block"); diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ProcessProgression.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ProcessProgression.java index fd6d756b..c0a05ffb 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ProcessProgression.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ProcessProgression.java @@ -25,7 +25,7 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.FurnaceBlockEntity; import net.minecraft.world.InteractionHand; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; import net.minecraftforge.registries.ForgeRegistries; @@ -78,8 +78,8 @@ static void hammer(ServerLevel world, ServerPlayer player, Map hammerClass = hammer.getItem().getClass(); if (!hammerClass.getName().equals("zone.moddev.mc.basemetals.content.CrackhammerItem")) throw new IllegalStateException("Unexpected hammer class " + hammerClass.getName()); RecipeType type = net.minecraft.core.registries.BuiltInRegistries.RECIPE_TYPE - .getOptional(ResourceLocation.fromNamespaceAndPath("basemetals", "crushing")).orElseThrow(); - net.minecraft.world.item.crafting.RecipeHolder selected = world.getRecipeManager().getRecipeFor( + .getOptional(Identifier.fromNamespaceAndPath("basemetals", "crushing")).orElseThrow(); + var selected = world.recipeAccess().getRecipeFor( (RecipeType>) type, new net.minecraft.world.item.crafting.SingleRecipeInput(input.copy()), world).orElse(null); selection.addProperty("status", selected == null ? "NO_MATCH" : "MATCH"); selection.addProperty("matched", selected != null); if (selected != null) { - selection.addProperty("id", selected.id().toString()); + selection.addProperty("id", selected.id().identifier().toString()); selection.addProperty("class", selected.value().getClass().getName()); - selection.addProperty("resultNbt", selected.value().getResultItem(world.registryAccess()) - .saveOptional(world.registryAccess()).toString()); + selection.addProperty("resultNbt", stackNbt(world, + selected.value().assemble(new net.minecraft.world.item.crafting.SingleRecipeInput(input.copy()), + world.registryAccess()))); } } catch (RuntimeException | LinkageError error) { selection.addProperty("status", "OBSERVER_ERROR"); @@ -154,22 +155,22 @@ private static JsonObject hammerRecipeSelection(ServerLevel world, ItemStack ham private static JsonObject hammerSnapshot(ServerLevel world, ServerPlayer player, BlockPos pos, AABB area, ItemEntity source) { JsonObject sample = new JsonObject(); - sample.addProperty("dimension", world.dimension().location().toString()); + sample.addProperty("dimension", world.dimension().identifier().toString()); sample.addProperty("gameTime", world.getGameTime()); sample.addProperty("area", area.toString()); sample.addProperty("sourceRemoved", source.isRemoved()); sample.addProperty("sourceBounds", source.getBoundingBox().toString()); sample.addProperty("sourceIntersectsArea", source.getBoundingBox().intersects(area)); - sample.addProperty("sourceNbt", source.getItem().saveOptional(world.registryAccess()).toString()); + sample.addProperty("sourceNbt", stackNbt(world, source.getItem())); sample.addProperty("uuidLookupIsSource", world.getEntity(source.getUUID()) == source); sample.addProperty("entitiesLoaded", world.areEntitiesLoaded(net.minecraft.world.level.ChunkPos.asLong(pos))); sample.addProperty("blockAndEntitiesTicking", Native118ChunkTicking.withEntitiesLoaded(world, pos)); sample.addProperty("entityTicking", world.isPositionEntityTicking(pos)); - sample.addProperty("clientSide", world.isClientSide); + sample.addProperty("clientSide", world.isClientSide()); sample.addProperty("creative", player.isCreative()); sample.addProperty("shift", player.isShiftKeyDown()); sample.addProperty("hammerClass", player.getMainHandItem().getItem().getClass().getName()); - sample.addProperty("hammerNbt", player.getMainHandItem().saveOptional(world.registryAccess()).toString()); + sample.addProperty("hammerNbt", stackNbt(world, player.getMainHandItem())); sample.addProperty("hammerDamage", player.getMainHandItem().getDamageValue()); sample.addProperty("hammerMaxDamage", player.getMainHandItem().getMaxDamage()); JsonArray items = new JsonArray(); @@ -181,7 +182,7 @@ private static JsonObject hammerSnapshot(ServerLevel world, ServerPlayer player, item.addProperty("isSource", entity == source); item.addProperty("removed", entity.isRemoved()); item.addProperty("bounds", entity.getBoundingBox().toString()); - item.addProperty("stackNbt", entity.getItem().saveOptional(world.registryAccess()).toString()); + item.addProperty("stackNbt", stackNbt(world, entity.getItem())); items.add(item); } sample.addProperty("sourceInAabb", sourceInAabb); @@ -189,6 +190,12 @@ private static JsonObject hammerSnapshot(ServerLevel world, ServerPlayer player, return sample; } + private static String stackNbt(ServerLevel world, ItemStack stack) { + return ItemStack.OPTIONAL_CODEC.encodeStart( + world.registryAccess().createSerializationContext(net.minecraft.nbt.NbtOps.INSTANCE), stack) + .getOrThrow().toString(); + } + static boolean canCrush(Map known, ItemStack input) { if (input.getItem() instanceof BlockItem) { for (String id : known.keySet()) { @@ -225,9 +232,12 @@ static boolean advance(ServerLevel world, ServerPlayer player, Map source.hasPermission(4)) + event.getDispatcher().register(Commands.literal("advprogression").requires(Commands.hasPermission(Commands.LEVEL_OWNERS)) .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { String label = StringArgumentType.getString(context, "label"); try { audit(context.getSource().getServer(), context.getSource().getLevel(), label); } @@ -91,14 +93,14 @@ private void audit(MinecraftServer server, ServerLevel world, String label) thro player.getAdvancements().revoke(advancement, criterion); } List> oldBook = new ArrayList<>(); - for (RecipeHolder recipe : server.getRecipeManager().getRecipes()) if (owned(recipe.id())) oldBook.add(recipe); + for (RecipeHolder recipe : server.getRecipeManager().getRecipes()) if (owned(recipe.id().identifier())) oldBook.add(recipe); player.resetRecipes(oldBook); ProcessProgression.reset(); Map known = new LinkedHashMap<>(); Set executed = new LinkedHashSet<>(); for (JsonElement seed : fixture.getAsJsonArray("naturalResources")) { String id = seed.getAsString(); - Item item = ForgeRegistries.ITEMS.getValue(ResourceLocation.parse(id)); + Item item = ForgeRegistries.ITEMS.getValue(Identifier.parse(id)); if (item != null) acquire(known, player, new ItemStack(item), "natural_resource", Collections.emptyList()); } JsonArray loot = new JsonArray(); @@ -125,7 +127,7 @@ private void audit(MinecraftServer server, ServerLevel world, String label) thro } } List> recipes = new ArrayList<>(server.getRecipeManager().getRecipes()); - recipes.sort(Comparator.comparing(recipe -> recipe.id().toString())); + recipes.sort(Comparator.comparing(recipe -> recipe.id().identifier().toString())); boolean changed = true; int rounds = 0; while (changed) { @@ -133,7 +135,8 @@ private void audit(MinecraftServer server, ServerLevel world, String label) thro changed = false; for (RecipeHolder holder : recipes) { Recipe recipe = holder.value(); - if (executed.contains(holder.id().toString()) || recipe.getIngredients().isEmpty()) continue; + Identifier recipeId = holder.id().identifier(); + if (executed.contains(recipeId.toString())) continue; String type = net.minecraft.core.registries.BuiltInRegistries.RECIPE_TYPE.getKey(recipe.getType()).toString(); boolean crafting = recipe.getType() == RecipeType.CRAFTING; boolean smelting = recipe.getType() == RecipeType.SMELTING; @@ -141,18 +144,10 @@ private void audit(MinecraftServer server, ServerLevel world, String label) thro if (!crafting && !smelting && !crushing) continue; if (smelting && (!known.containsKey("minecraft:furnace") || !known.containsKey("minecraft:coal"))) continue; if (crushing && known.keySet().stream().noneMatch(key -> key.startsWith("basemetals:") && key.endsWith("_crackhammer"))) continue; - List ingredients = new ArrayList<>(); - boolean possible = true; - for (Ingredient ingredient : recipe.getIngredients()) { - if (ingredient == Ingredient.EMPTY) { ingredients.add(ItemStack.EMPTY); continue; } - ItemStack chosen = ItemStack.EMPTY; - for (ItemStack candidate : ingredient.getItems()) { - if (!candidate.isEmpty() && known.containsKey(id(candidate))) { chosen = candidate.copy(); chosen.setCount(1); break; } - } - if (chosen.isEmpty()) { possible = false; break; } - ingredients.add(chosen); - } - if (!possible) continue; + // Special crushing recipes deliberately have no recipe-book placement information. + List ingredients = crushing ? knownSingleInput(recipe, known.keySet(), world) + : knownIngredients(recipe.placementInfo(), known.keySet()); + if (ingredients == null || ingredients.isEmpty()) continue; if (crushing && !ProcessProgression.canCrush(known, ingredients.get(0))) continue; ItemStack result = ItemStack.EMPTY; if (crafting) { @@ -174,20 +169,20 @@ private void audit(MinecraftServer server, ServerLevel world, String label) thro result = matchedResult(recipe, grid, world); } if (result.isEmpty()) continue; - if (owned(holder.id()) && !crushing) { - AdvancementHolder unlock = server.getAdvancements().get(ResourceLocation.fromNamespaceAndPath( - holder.id().getNamespace(), "recipes/" + holder.id().getPath())); + if (owned(recipeId) && !crushing) { + AdvancementHolder unlock = server.getAdvancements().get(Identifier.fromNamespaceAndPath( + recipeId.getNamespace(), "recipes/" + recipeId.getPath())); require(unlock != null && player.getAdvancements().getOrStartProgress(unlock).isDone() - && player.getRecipeBook().contains(holder), "Manufacture lacks ingredient discovery: " + holder.id()); + && player.getRecipeBook().contains(holder.id()), "Manufacture lacks ingredient discovery: " + recipeId); } - if (owned(holder.id()) && smelting) - ProcessProgression.smelt(world, fixture, holder.id().toString(), ingredients.get(0), result); - if (owned(holder.id()) && crushing && !(ingredients.get(0).getItem() instanceof net.minecraft.world.item.BlockItem)) - ProcessProgression.hammer(world, player, known, fixture, holder.id().toString(), ingredients.get(0), result); - executed.add(holder.id().toString()); + if (owned(recipeId) && smelting) + ProcessProgression.smelt(world, fixture, recipeId.toString(), ingredients.get(0), result); + if (owned(recipeId) && crushing && !(ingredients.get(0).getItem() instanceof net.minecraft.world.item.BlockItem)) + ProcessProgression.hammer(world, player, known, fixture, recipeId.toString(), ingredients.get(0), result); + executed.add(recipeId.toString()); List inputs = new ArrayList<>(); for (ItemStack ingredient : ingredients) if (!ingredient.isEmpty()) inputs.add(id(ingredient)); - changed |= acquire(known, player, result, holder.id().toString(), inputs); + changed |= acquire(known, player, result, recipeId.toString(), inputs); } changed |= ProcessProgression.advance(world, player, known, fixture); } @@ -215,9 +210,9 @@ private void audit(MinecraftServer server, ServerLevel world, String label) thro JsonArray missingRecipes = new JsonArray(); for (RecipeHolder holder : recipes) { Recipe recipe = holder.value(); - if (!owned(holder.id()) || (recipe.getType() != RecipeType.CRAFTING + if (!owned(holder.id().identifier()) || (recipe.getType() != RecipeType.CRAFTING && recipe.getType() != RecipeType.SMELTING)) continue; - if (!executed.contains(holder.id().toString())) missingRecipes.add(holder.id().toString()); + if (!executed.contains(holder.id().identifier().toString())) missingRecipes.add(holder.id().identifier().toString()); } if (missingRecipes.size() > 0) blocked += missingRecipes.size(); report.addProperty("status", blocked == 0 ? "PASS" : "FAIL"); @@ -236,7 +231,7 @@ private void audit(MinecraftServer server, ServerLevel world, String label) thro JsonObject tags = new JsonObject(); for (String name : new String[] {"wires", "dusts/carbon", "rubber", "plastic", "circuit_boards", "solder"}) { List members = new ArrayList<>(); - for (var item : net.minecraft.core.registries.BuiltInRegistries.ITEM.getTagOrEmpty(ItemTags.create(ResourceLocation.fromNamespaceAndPath("forge", name)))) + for (var item : net.minecraft.core.registries.BuiltInRegistries.ITEM.getTagOrEmpty(ItemTags.create(Identifier.fromNamespaceAndPath("forge", name)))) members.add(net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(item.value()).toString()); Collections.sort(members); tags.add(name, JSON.toJsonTree(members)); @@ -264,14 +259,48 @@ static JsonArray inputJson(List inputs) { return result; } + static List knownIngredients(PlacementInfo placement, Set known) { + if (placement.isImpossibleToPlace()) return Collections.emptyList(); + List slots = new ArrayList<>(); + for (int index : placement.slotsToIngredientIndex()) { + if (index == PlacementInfo.EMPTY_SLOT) { + slots.add(ItemStack.EMPTY); + continue; + } + Ingredient ingredient = placement.ingredients().get(index); + ItemStack chosen = ItemStack.EMPTY; + for (String id : known) { + ItemStack candidate = stack(id); + if (!candidate.isEmpty() && ingredient.test(candidate)) { + chosen = candidate; + break; + } + } + if (chosen.isEmpty()) return null; + slots.add(chosen); + } + return slots; + } + + @SuppressWarnings("unchecked") + static List knownSingleInput(Recipe recipe, Set known, ServerLevel world) { + Recipe typed = (Recipe) recipe; + for (String id : known) { + ItemStack candidate = stack(id); + if (!candidate.isEmpty() && typed.matches(new SingleRecipeInput(candidate), world)) + return Collections.singletonList(candidate); + } + return Collections.emptyList(); + } + static ItemStack stack(String id) { - Item item = ForgeRegistries.ITEMS.getValue(ResourceLocation.parse(id)); + Item item = ForgeRegistries.ITEMS.getValue(Identifier.parse(id)); return item == null ? ItemStack.EMPTY : new ItemStack(item); } private static net.minecraft.world.level.storage.loot.LootTable lootTable(MinecraftServer server, String id) { return server.reloadableRegistries().getLootTable(net.minecraft.resources.ResourceKey.create( - net.minecraft.core.registries.Registries.LOOT_TABLE, ResourceLocation.parse(id))); + net.minecraft.core.registries.Registries.LOOT_TABLE, Identifier.parse(id))); } private static LootParams chestParams(ServerLevel world) { @@ -289,7 +318,7 @@ private static ItemStack matchedResult(Recipe recipe, net.minecraft.world.Con } private static boolean advantage(ItemStack stack) { return !stack.isEmpty() && owned(net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(stack.getItem())); } - private static boolean owned(ResourceLocation id) { + private static boolean owned(Identifier id) { return id != null && (id.getNamespace().equals("basepower") || id.getNamespace().equals("basesteam") || id.getNamespace().equals("baseelectric")); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/RecipeBookProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/RecipeBookProbe.java index cfff2d67..74e1bcc4 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/RecipeBookProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/RecipeBookProbe.java @@ -6,16 +6,28 @@ import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.inventory.CraftingContainer; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.crafting.Recipe; import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; +import net.minecraft.world.item.crafting.display.SlotDisplayContext; +import net.minecraft.util.context.ContextMap; import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.protocol.game.ClientboundUpdateRecipesPacket; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.common.ClientboundUpdateTagsPacket; +import net.minecraft.network.protocol.game.ClientboundRecipeBookAddPacket; +import net.minecraft.tags.TagNetworkSerialization; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.item.crafting.RecipeType; import static zone.moddev.mc.advantageworks.ProgressionProbe.*; @@ -24,34 +36,65 @@ final class RecipeBookProbe { static void verify(MinecraftServer server, ServerLevel world, String label, JsonObject report) throws Exception { JsonArray entries = new JsonArray(); - List> recipes = new ArrayList<>(); + List displays = new ArrayList<>(); + Map groupOutputs = new HashMap<>(); + ContextMap displayContext = SlotDisplayContext.fromLevel(world); for (RecipeHolder holder : server.getRecipeManager().getRecipes()) { Recipe recipe = holder.value(); - String namespace = holder.id().getNamespace(); + Identifier recipeId = holder.id().identifier(); + String namespace = recipeId.getNamespace(); if (!(namespace.equals("basepower") || namespace.equals("basesteam") || namespace.equals("baseelectric"))) continue; if (recipe.getType() != RecipeType.CRAFTING && recipe.getType() != RecipeType.SMELTING) continue; - String output = id(recipe.getResultItem(world.registryAccess())); - require(output.equals(recipe.getGroup()), "Mixed-output recipe group: " + holder.id() + " -> " + recipe.getGroup()); + List recipeDisplays = new ArrayList<>(); + server.getRecipeManager().listDisplaysForRecipe(holder.id(), recipeDisplays::add); + require(!recipeDisplays.isEmpty(), "Recipe has no native book display: " + recipeId); + ItemStack result = displayResult(recipeDisplays.getFirst(), displayContext); + String output = id(result); + require(output.equals(recipe.group()), "Mixed-output recipe group: " + recipeId + " -> " + recipe.group()); JsonObject entry = new JsonObject(); - entry.addProperty("id", holder.id().toString()); - entry.addProperty("group", recipe.getGroup()); + entry.addProperty("id", recipeId.toString()); + entry.addProperty("group", recipe.group()); entry.addProperty("type", net.minecraft.core.registries.BuiltInRegistries.RECIPE_TYPE.getKey(recipe.getType()).toString()); entry.addProperty("output", output); - entry.addProperty("count", recipe.getResultItem(world.registryAccess()).getCount()); + entry.addProperty("count", result.getCount()); + JsonArray displayIds = new JsonArray(); + for (RecipeDisplayEntry display : recipeDisplays) { + require(ItemStack.matches(result, displayResult(display, displayContext)), + "Recipe displays disagree on output: " + recipeId); + require(display.group().isPresent(), "Recipe display lost its book group: " + recipeId); + String previous = groupOutputs.putIfAbsent(display.group().getAsInt(), output); + require(previous == null || previous.equals(output), "Native book group mixes outputs: " + recipeId); + displayIds.add(display.id().index()); + displays.add(new ClientboundRecipeBookAddPacket.Entry(display, false, false)); + } + entry.add("displayIds", displayIds); entries.add(entry); - recipes.add(holder); } report.add("bookRecipes", entries); + report.addProperty("bookPacketType", "ClientboundRecipeBookAddPacket"); + var tags = TagNetworkSerialization.serializeTagsToNetwork(server.registries()); + byte[] tagBytes = itemTagsPacket(tags); + String tagFile = "tags-" + label + ".bin"; + Files.write(Paths.get(tagFile), tagBytes); + report.addProperty("tagsPacketType", "ClientboundUpdateTagsPacket"); + report.addProperty("tagsPacketFile", tagFile); + report.addProperty("tagsPacketSha256", java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(tagBytes))); + report.addProperty("itemTagCount", tags.get(Registries.ITEM).size()); RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), world.registryAccess()); try { - ClientboundUpdateRecipesPacket.STREAM_CODEC.encode(buffer, new ClientboundUpdateRecipesPacket(recipes)); + ClientboundRecipeBookAddPacket.STREAM_CODEC.encode(buffer, new ClientboundRecipeBookAddPacket(displays, true)); byte[] bytes = new byte[buffer.readableBytes()]; - buffer.readBytes(bytes); + buffer.getBytes(buffer.readerIndex(), bytes); + ClientboundRecipeBookAddPacket decoded = ClientboundRecipeBookAddPacket.STREAM_CODEC.decode(buffer); + require(!buffer.isReadable() && decoded.replace() && decoded.entries().equals(displays), + "Native recipe-book packet did not round-trip"); Files.write(Paths.get("recipes-" + label + ".bin"), bytes); } finally { buffer.release(); } - ResourceLocation starchId = ResourceLocation.parse("basepower:starch_from_potato"); - Recipe starch = server.getRecipeManager().byKey(starchId).map(RecipeHolder::value).orElse(null); - require(starch != null && starch.getType() == RecipeType.CRAFTING, "Missing native potato crafting recipe"); + var starchId = RecipeUnlockProbe.recipeKey("basepower:starch_from_potato"); + Recipe starchRecipe = server.getRecipeManager().byKey(starchId).map(RecipeHolder::value).orElse(null); + require(starchRecipe instanceof CraftingRecipe, "Missing native potato crafting recipe"); + CraftingRecipe starch = (CraftingRecipe) starchRecipe; int cases = 0; for (int width : new int[] {2, 3}) for (String potato : new String[] {"minecraft:potato", "minecraft:poisonous_potato"}) { for (int slot = 0; slot < width * width; slot++) for (int count : new int[] {1, 64}) { @@ -60,7 +103,7 @@ static void verify(MinecraftServer server, ServerLevel world, String label, Json input.setCount(count); grid.setItem(slot, input); require(starch.matches(grid.asCraftInput(), world), "Potato recipe rejected slot/stack " + slot + "/" + count); - RecipeHolder selected = world.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, grid.asCraftInput(), world).orElse(null); + RecipeHolder selected = world.recipeAccess().getRecipeFor(RecipeType.CRAFTING, grid.asCraftInput(), world).orElse(null); require(selected != null && selected.id().equals(starchId), "Conflicting native potato crafting recipe"); ItemStack result = starch.assemble(grid.asCraftInput(), world.registryAccess()); require(id(result).equals("basepower:starch") && result.getCount() == 1, "Starch output changed"); @@ -87,6 +130,28 @@ static void verify(MinecraftServer server, ServerLevel world, String label, Json report.addProperty("potatoCraftingCases", cases); } + static ItemStack displayResult(RecipeDisplayEntry display, ContextMap context) { + List results = display.resultItems(context); + require(results.size() == 1 && !results.getFirst().isEmpty(), + "Recipe display must have exactly one nonempty output: " + display.id()); + return results.getFirst(); + } + + static byte[] itemTagsPacket(Map>, TagNetworkSerialization.NetworkPayload> tags) { + var items = tags.get(Registries.ITEM); + require(items != null && !items.isEmpty(), "Native recipe export requires the real server item tags"); + FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer()); + try { + ClientboundUpdateTagsPacket.STREAM_CODEC.encode(buffer, new ClientboundUpdateTagsPacket(tags)); + byte[] bytes = new byte[buffer.readableBytes()]; + buffer.getBytes(buffer.readerIndex(), bytes); + var decoded = ClientboundUpdateTagsPacket.STREAM_CODEC.decode(buffer); + require(!buffer.isReadable() && decoded.getTags().keySet().equals(tags.keySet()), + "Native tag packet did not round-trip its registry set"); + return bytes; + } finally { buffer.release(); } + } + private static CraftingContainer grid(int width) { return new net.minecraft.world.inventory.TransientCraftingContainer(new AbstractContainerMenu(null, 0) { @Override public boolean stillValid(net.minecraft.world.entity.player.Player player) { return true; } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/RecipeUnlockProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/RecipeUnlockProbe.java index 6ed19674..3a2310f5 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/RecipeUnlockProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/RecipeUnlockProbe.java @@ -18,7 +18,7 @@ import java.util.UUID; import net.minecraft.advancements.AdvancementHolder; import net.minecraft.advancements.CriteriaTriggers; -import net.minecraft.advancements.critereon.InventoryChangeTrigger; +import net.minecraft.advancements.criterion.InventoryChangeTrigger; import net.minecraft.commands.Commands; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.Item; @@ -31,7 +31,7 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayerGameMode; import net.minecraft.tags.ItemTags; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.entity.player.PlayerEvent; @@ -48,8 +48,8 @@ final class RecipeUnlockProbe { static void register() { if (MARKER.isFile()) { RecipeUnlockProbe probe = new RecipeUnlockProbe(); - MinecraftForge.EVENT_BUS.addListener(probe::starting); - MinecraftForge.EVENT_BUS.addListener(probe::stopped); + RegisterCommandsEvent.BUS.addListener(probe::starting); + net.minecraftforge.event.server.ServerStoppedEvent.BUS.addListener(probe::stopped); } } @@ -66,7 +66,7 @@ private void stopped(net.minecraftforge.event.server.ServerStoppedEvent event) { private void starting(RegisterCommandsEvent event) { event.getDispatcher().register(Commands.literal("advrecipes") - .requires(source -> source.hasPermission(4)) + .requires(Commands.hasPermission(Commands.LEVEL_OWNERS)) .then(Commands.argument("operation", StringArgumentType.word()) .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { String operation = StringArgumentType.getString(context, "operation"); @@ -112,7 +112,7 @@ private void run(MinecraftServer server, ServerLevel world, String operation, St JsonObject definition = element.getAsJsonObject(); String id = definition.get("id").getAsString(); AdvancementHolder advancement = advancement(server, id); - RecipeHolder recipe = server.getRecipeManager().byKey(ResourceLocation.parse(id)).orElse(null); + RecipeHolder recipe = server.getRecipeManager().byKey(recipeKey(id)).orElse(null); JsonObject row = new JsonObject(); row.addProperty("id", id); row.addProperty("recipeActive", recipe != null); @@ -139,7 +139,7 @@ private void run(MinecraftServer server, ServerLevel world, String operation, St player.getInventory().setItem(0, new ItemStack(choices.get(0))); CriteriaTriggers.INVENTORY_CHANGED.trigger(player, player.getInventory(), player.getInventory().getItem(0)); require(player.getAdvancements().getOrStartProgress(advancement).isDone(), "Inventory event did not discover " + id); - require(recipe == null || player.getRecipeBook().contains(recipe), "Reward failed for " + id); + require(recipe == null || player.getRecipeBook().contains(recipe.id()), "Reward failed for " + id); positives++; } // Also exercise /recipe-style discovery and its has_recipe criterion. @@ -157,11 +157,11 @@ private void run(MinecraftServer server, ServerLevel world, String operation, St } else if (operation.equals("persist")) { for (JsonElement element : cases) { String id = element.getAsJsonObject().get("id").getAsString(); - reset(advancement(server, id), server.getRecipeManager().byKey(ResourceLocation.parse(id)).orElse(null)); + reset(advancement(server, id), server.getRecipeManager().byKey(recipeKey(id)).orElse(null)); } JsonArray kit = new JsonArray(); for (JsonElement id : fixture.getAsJsonArray("materials")) { - ResourceLocation name = ResourceLocation.parse(id.getAsString()); + Identifier name = Identifier.parse(id.getAsString()); if (!ForgeRegistries.ITEMS.containsKey(name)) continue; discover(item(id.getAsString())); kit.add(id.getAsString()); @@ -189,13 +189,13 @@ private void run(MinecraftServer server, ServerLevel world, String operation, St result.add("additionalMaterials", additions); verifyDiscovered(server, cases, rows); } else if (operation.equals("restore")) { - require(server.getPlayerList().load(player).isPresent(), "No saved test player"); + Native117PlayerIo.load(server.getPlayerList(), player); require(transport.channel().isActive(), "Native recipe-login channel is not active"); require(net.minecraftforge.network.NetworkContext.get(transport).getType() == net.minecraftforge.network.ConnectionType.VANILLA, "Server-only probe must not impersonate a remote modded-client handshake"); // Dispatch the real Forge event to exercise all three namespaces' restoration. - MinecraftForge.EVENT_BUS.post(new PlayerEvent.PlayerLoggedInEvent(player)); + PlayerEvent.PlayerLoggedInEvent.BUS.post(new PlayerEvent.PlayerLoggedInEvent(player)); result.addProperty("nativeForgeLoginEvent", true); require(player.getInventory().isEmpty(), "Persistence test must not reacquire ingredients"); verifyDiscovered(server, cases, rows); @@ -213,8 +213,8 @@ private void verifyDiscovered(MinecraftServer server, JsonArray definitions, Jso available |= !choices(criterion.getValue().getAsJsonObject()).isEmpty(); boolean done = player.getAdvancements().getOrStartProgress(advancement(server, id)).isDone(); require(!available || done, "Lost discovery: " + id); - RecipeHolder recipe = server.getRecipeManager().byKey(ResourceLocation.parse(id)).orElse(null); - require(recipe == null || !available || player.getRecipeBook().contains(recipe), "Lost recipe reward: " + id); + RecipeHolder recipe = server.getRecipeManager().byKey(recipeKey(id)).orElse(null); + require(recipe == null || !available || player.getRecipeBook().contains(recipe.id()), "Lost recipe reward: " + id); JsonObject row = new JsonObject(); row.addProperty("id", id); row.addProperty("recipeActive", recipe != null); @@ -238,8 +238,13 @@ private void reset(AdvancementHolder advancement, RecipeHolder recipe) { } private static AdvancementHolder advancement(MinecraftServer server, String id) { - ResourceLocation name = ResourceLocation.parse(id); - return server.getAdvancements().get(ResourceLocation.fromNamespaceAndPath(name.getNamespace(), "recipes/" + name.getPath())); + Identifier name = Identifier.parse(id); + return server.getAdvancements().get(Identifier.fromNamespaceAndPath(name.getNamespace(), "recipes/" + name.getPath())); + } + + static net.minecraft.resources.ResourceKey> recipeKey(String id) { + return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE, + Identifier.parse(id)); } private static boolean matchesInventory(InventoryChangeTrigger.TriggerInstance condition, @@ -260,13 +265,13 @@ private static List choices(JsonObject predicate) { if (predicate.has("item")) return Collections.singletonList(item(predicate.get("item").getAsString())); List items = new ArrayList<>(); for (var holder : net.minecraft.core.registries.BuiltInRegistries.ITEM.getTagOrEmpty( - ItemTags.create(ResourceLocation.parse(predicate.get("tag").getAsString())))) items.add(holder.value()); + ItemTags.create(Identifier.parse(predicate.get("tag").getAsString())))) items.add(holder.value()); items.sort(Comparator.comparing(candidate -> net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(candidate).toString())); return items; } private static Item item(String id) { - ResourceLocation name = ResourceLocation.parse(id); + Identifier name = Identifier.parse(id); Item item = ForgeRegistries.ITEMS.getValue(name); require(item != null, "Missing test ingredient: " + id); return item; diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/RedstoneReloadProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/RedstoneReloadProbe.java index 8feb4161..7c51d6ce 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/RedstoneReloadProbe.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/RedstoneReloadProbe.java @@ -26,14 +26,14 @@ import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.core.Direction; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.BlockPos; import net.minecraft.core.Registry; import net.minecraft.world.level.Level; import net.minecraft.server.level.ServerLevel; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.RegisterCommandsEvent; -import net.minecraftforge.event.TickEvent; + /** Separate cold-process experiment: finite earned work, never expected-state restoration. */ final class RedstoneReloadProbe { @@ -63,8 +63,8 @@ private enum Phase { FROZEN, PREPARING, SEEDING, RESTORING, OPERATING, DONE } static void register() { if (!MARKER.isFile()) return; RedstoneReloadProbe probe = new RedstoneReloadProbe(); - MinecraftForge.EVENT_BUS.addListener(probe::commands); - MinecraftForge.EVENT_BUS.addListener(probe::tick); + RegisterCommandsEvent.BUS.addListener(probe::commands); + NativeTickEvents.listenLevel(probe::tick); } private static List definitions() { @@ -80,7 +80,7 @@ private static List definitions() { private void commands(RegisterCommandsEvent event) { event.getDispatcher().register(Commands.literal("advredstonereload") - .requires(source -> source.hasPermission(4)) + .requires(source -> source.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_OWNER)) .then(Commands.argument("operation", StringArgumentType.word()) .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { if (!MARKER.isFile() || used) throw new IllegalStateException("Missing marker or reused probe"); @@ -112,10 +112,10 @@ private void commands(RegisterCommandsEvent event) { private boolean paused() { return phase != Phase.SEEDING && phase != Phase.OPERATING; } - private void tick(TickEvent.LevelTickEvent event) { - if (!(event.level instanceof ServerLevel) || event.level.dimension() != Level.OVERWORLD) return; - ServerLevel current = (ServerLevel) event.level; - if (event.phase == TickEvent.Phase.START) { + private void tick(NativeTickEvents.LevelTickEvent event) { + if (!(event.level() instanceof ServerLevel) || event.level().dimension() != Level.OVERWORLD) return; + ServerLevel current = (ServerLevel) event.level(); + if (event.phase() == NativeTickEvents.Phase.START) { tickStart = current.getGameTime(); if (paused()) { suspended.addAll(NativeBlockEntityTicks.drain(current)); @@ -138,7 +138,7 @@ private void tick(TickEvent.LevelTickEvent event) { boolean ready = true; for (Witness witness : witnesses) { CompoundTag tag = WorksBlockData.save(tile(witness)); - ready &= tag.getShort("progress") > 0 && tag.contains("targetX", 99) + ready &= tag.getShort("progress").orElseThrow() > 0 && zone.moddev.mc.basepower.util.NbtCompat.containsTag(tag, "targetX", 99) && ((PoweredEntity) tile(witness)).getEnergy(new ConduitType("electricity")) > 24; } if (ready) freezeSeed(); @@ -218,7 +218,7 @@ private void construct(Witness witness) { force(witness); for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) for (int dz = -1; dz <= 1; dz++) world.setBlock(witness.pos.offset(dx, dy, dz), Blocks.AIR.defaultBlockState(), 2); - Block block = net.minecraft.core.registries.BuiltInRegistries.BLOCK.get(ResourceLocation.parse("baseelectric:electric_drill")); + Block block = net.minecraft.core.registries.BuiltInRegistries.BLOCK.getValue(Identifier.parse("baseelectric:electric_drill")); if (block == Blocks.AIR) throw new IllegalStateException("Electric drill registry absent"); world.setBlock(witness.pos, block.defaultBlockState().setValue(BlockStateProperties.FACING, Direction.NORTH), 2); for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) @@ -286,7 +286,7 @@ private List> snapshots() { row.put("signal", world.hasNeighborSignal(witness.pos)); row.put("holdBlock", net.minecraftforge.registries.ForgeRegistries.BLOCKS.getKey(world.getBlockState(witness.pos.above()).getBlock()).toString()); row.put("energy", ((PoweredEntity) tile).getEnergy(new ConduitType("electricity"))); - row.put("progress", (int) nbt.getShort("progress")); + row.put("progress", (int) nbt.getShort("progress").orElseThrow()); row.put("nbt", nbt.toString()); List> targets = new ArrayList<>(); for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalActors.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalActors.java index 475d58cc..d9aadb5f 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalActors.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalActors.java @@ -2,6 +2,7 @@ import com.mojang.authlib.GameProfile; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import net.minecraft.world.level.block.state.BlockState; @@ -9,7 +10,7 @@ import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.ai.attributes.Attributes; import net.minecraft.world.entity.decoration.ArmorStand; -import net.minecraft.world.entity.animal.Cow; +import net.minecraft.world.entity.animal.cow.Cow; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.nbt.CompoundTag; @@ -20,7 +21,7 @@ import net.minecraft.world.level.block.state.properties.IntegerProperty; import net.minecraft.world.InteractionResult; import net.minecraft.world.InteractionHand; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.BlockPos; import net.minecraft.world.level.GameType; import net.minecraftforge.registries.ForgeRegistries; @@ -86,7 +87,7 @@ private static void elevator(List cases) { } c.require(c.world.getBlockState(c.at(8, 9, 8)).getBlock() == net.minecraft.world.level.block.Blocks.STONE, "Elevator preserves the real upper stop"); - c.require(c.nbt(8, 0, 8).getBoolean("up") == movedUp, + c.require(c.nbt(8, 0, 8).getBoolean("up").orElseThrow() == movedUp, "Saved latch agrees with physical movement, not just an NBT flag"); })); } @@ -106,6 +107,8 @@ private static final class Shot { ItemStack weapon; float expectedDamage; int expectedAmmo; + ItemStack unladenWeapon; + List unladenInventory; } private static void musket(List cases) { @@ -126,8 +129,8 @@ private static void musket(List cases) { @Override public void send(Packet packet) {} }; shot.player.gameMode.changeGameModeForPlayer(GameType.SURVIVAL); - shot.player.moveTo(muzzle.getX() + 0.5, muzzle.getY(), muzzle.getZ() + 0.5, 0, 0); - Item weapon = ForgeRegistries.ITEMS.getValue(ResourceLocation.parse("basesteam:musket")); + shot.player.snapTo(muzzle.getX() + 0.5, muzzle.getY(), muzzle.getZ() + 0.5, 0, 0); + Item weapon = ForgeRegistries.ITEMS.getValue(Identifier.parse("basesteam:musket")); c.require(weapon != null && weapon != net.minecraft.world.item.Items.AIR, "Native musket is registered"); shot.weapon = new ItemStack(weapon); shot.player.setItemInHand(InteractionHand.MAIN_HAND, shot.weapon); @@ -135,33 +138,45 @@ private static void musket(List cases) { if (mode.equals("powderless")) enchant(c, shot.weapon, "basesteam:powderless"); if (mode.equals("infinity")) enchant(c, shot.weapon, "minecraft:infinity"); if (mode.equals("last-cartridge") || mode.equals("infinity")) { - Item cartridge = ForgeRegistries.ITEMS.getValue(ResourceLocation.parse("basesteam:blackpowder_cartridge")); + Item cartridge = ForgeRegistries.ITEMS.getValue(Identifier.parse("basesteam:blackpowder_cartridge")); c.require(cartridge != null && cartridge != net.minecraft.world.item.Items.AIR, "Cartridge is registered"); shot.player.getInventory().setItem(1, new ItemStack(cartridge)); } shot.expectedAmmo = mode.equals("infinity") ? 1 : 0; - shot.target = EntityType.COW.create(c.world); + shot.target = EntityType.COW.create(c.world, net.minecraft.world.entity.EntitySpawnReason.COMMAND); c.require(shot.target != null, "Native living target created"); shot.target.getAttribute(Attributes.MAX_HEALTH).setBaseValue(1000); shot.target.setHealth(1000); - shot.target.moveTo(muzzle.getX() + 0.5, muzzle.getY() + 0.5, muzzle.getZ() + 4.5, 0, 0); + shot.target.snapTo(muzzle.getX() + 0.5, muzzle.getY() + 0.5, muzzle.getZ() + 4.5, 0, 0); shot.target.setNoAi(true); shot.target.setNoGravity(true); shot.target.setPersistenceRequired(); c.require(c.world.addFreshEntity(shot.target), "Living target added to the real server world"); c.record("target/uuid", shot.target.getUUID().toString()); c.record("target/initialHealth", shot.target.getHealth()); - InteractionResult reload = weapon.use(c.world, shot.player, InteractionHand.MAIN_HAND).getResult(); + if (mode.equals("no-ammo")) { + shot.unladenWeapon = shot.weapon.copy(); + shot.unladenInventory = new ArrayList<>(); + for (int slot = 0; slot < shot.player.getInventory().getContainerSize(); slot++) + shot.unladenInventory.add(shot.player.getInventory().getItem(slot).copy()); + c.require(!WorksItemData.read(shot.weapon).contains("loaded"), + "Fresh native musket has no fabricated chamber state"); + } + InteractionResult reload = weapon.use(c.world, shot.player, InteractionHand.MAIN_HAND); c.record("musket/reload-result", reload.toString()); if (mode.equals("no-ammo")) { c.require(reload == InteractionResult.FAIL, "Unloaded musket rejects use without ammunition"); + c.require(rejectedUnladenUse(reload, shot.player.isUsingItem(), shot.unladenWeapon, shot.weapon), + "Rejected native use neither starts reloading nor changes any weapon component"); + c.require(sameInventory(shot.player.getInventory(), shot.unladenInventory), + "Rejected native use preserves every inventory stack and ammunition count"); shot.expectedDamage = 0; } else { c.require(reload == InteractionResult.SUCCESS, "Rapid-reload interaction succeeds"); - c.require(WorksItemData.read(shot.weapon).getByte("loaded") == 1, + c.require(WorksItemData.read(shot.weapon).getByte("loaded").orElseThrow() == 1, "Reload interaction loads the chamber before the shot"); c.record("musket/ammo-after-reload", shot.player.getInventory().getItem(1).getCount()); c.require(shot.player.getInventory().getItem(1).getCount() == shot.expectedAmmo, "Reload consumes exactly one cartridge unless an ammunition-saving enchantment applies"); - InteractionResult aim = weapon.use(c.world, shot.player, InteractionHand.MAIN_HAND).getResult(); + InteractionResult aim = weapon.use(c.world, shot.player, InteractionHand.MAIN_HAND); c.record("musket/aim-result", aim.toString()); c.require(aim == InteractionResult.SUCCESS, "Loaded musket can aim even without a spare cartridge"); c.require(shot.player.isUsingItem(), "Native player enters the use lifecycle"); @@ -169,6 +184,14 @@ private static void musket(List cases) { shot.expectedDamage = shotDamage(); } }, c -> { + if (mode.equals("no-ammo")) { + InteractionResult retry = shot.weapon.getItem().use(c.world, shot.player, InteractionHand.MAIN_HAND); + c.record("musket/rejected-retry-result", retry.toString()); + c.require(rejectedUnladenUse(retry, shot.player.isUsingItem(), shot.unladenWeapon, shot.weapon), + "Unladen native use still fails after server ticks with weapon and absent chamber state unchanged"); + c.require(sameInventory(shot.player.getInventory(), shot.unladenInventory), + "Repeated rejected use preserves the complete inventory and ammunition"); + } c.record("target/finalHealth", shot.target.getHealth()); c.record("target/naturalTicks", shot.target.tickCount); c.record("musket/durabilityDebit", shot.weapon.getDamageValue()); @@ -181,13 +204,27 @@ private static void musket(List cases) { "Only a real shot debits one musket durability"); c.require(shot.player.getInventory().getItem(1).getCount() == shot.expectedAmmo, "Firing does not charge ammunition a second time"); - c.require(WorksItemData.read(shot.weapon).getByte("loaded") == 0, - "The chamber is empty after firing or failed unladen use"); + if (!mode.equals("no-ammo")) + c.require(WorksItemData.read(shot.weapon).getByte("loaded").orElseThrow() == 0, + "The fired chamber has an explicit native empty state"); c.require(!shot.player.isCreative(), "All ammunition and durability measurements use survival mode"); })); } } + static boolean rejectedUnladenUse(InteractionResult result, boolean usingItem, ItemStack before, ItemStack after) { + return result == InteractionResult.FAIL && !usingItem + && !WorksItemData.read(before).contains("loaded") && !WorksItemData.read(after).contains("loaded") + && ItemStack.matches(before, after); + } + + static boolean sameInventory(net.minecraft.world.Container inventory, List before) { + if (inventory.getContainerSize() != before.size()) return false; + for (int slot = 0; slot < before.size(); slot++) + if (!ItemStack.matches(before.get(slot), inventory.getItem(slot))) return false; + return true; + } + private static float shotDamage() { try { float damage = Class.forName("zone.moddev.mc.basesteam.SteamAdvantage", false, @@ -201,7 +238,7 @@ private static float shotDamage() { private static void enchant(MachineContext c, ItemStack weapon, String id) { var enchantment = c.world.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.ENCHANTMENT) - .getOrThrow(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.ENCHANTMENT, ResourceLocation.parse(id))); + .getOrThrow(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.ENCHANTMENT, Identifier.parse(id))); weapon.enchant(enchantment, 1); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalCases.java index 2a22e901..d52d769d 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalCases.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalCases.java @@ -7,7 +7,7 @@ import net.minecraft.world.level.material.Fluid; import net.minecraft.world.item.ItemStack; import net.minecraft.core.Direction; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.fluids.capability.IFluidHandler; import net.minecraftforge.registries.ForgeRegistries; @@ -60,7 +60,7 @@ private static int amount(MachineContext c, String getter) { // This is an actual face-capability insertion, not c.tank's direct reserve seeding. private static void fill(MachineContext c, String id, int amount) { - Fluid fluid = ForgeRegistries.FLUIDS.getValue(ResourceLocation.parse(id)); + Fluid fluid = ForgeRegistries.FLUIDS.getValue(Identifier.parse(id)); c.require(fluid != null, "Registered input fluid " + id); IFluidHandler handler = c.tile(X, Y, Z).getCapability( net.minecraftforge.common.capabilities.ForgeCapabilities.FLUID_HANDLER, Direction.UP) @@ -78,14 +78,14 @@ private static void reserve(MachineContext c, String key, String fluid, int amou else if (key.equals("TankOut")) getter = "getOutputTank"; else throw new IllegalArgumentException("Unknown Steam reserve tank " + key); FluidTank tank = tank(c, getter); - Fluid type = ForgeRegistries.FLUIDS.getValue(ResourceLocation.parse(fluid)); + Fluid type = ForgeRegistries.FLUIDS.getValue(Identifier.parse(fluid)); c.require(type != null && amount > 0 && amount <= tank.getCapacity(), "Valid finite setup reserve for " + key); // Replace the complete tank value: recursively merged NBT retains an old Empty marker. tank.setFluid(new FluidStack(type, amount)); c.tile(X, Y, Z).setChanged(); c.record("setup/reserve/" + key, fluid + ":" + amount); fluid(c, getter, fluid, amount); - c.require(!c.nbt(X, Y, Z).getCompound(key).contains("Empty"), "Finite reserve is serialized as nonempty before ticking"); + c.require(!c.nbt(X, Y, Z).getCompound(key).orElseThrow().contains("Empty"), "Finite reserve is serialized as nonempty before ticking"); } private static void slot(MachineContext c, int index, String item, int count) { @@ -149,7 +149,14 @@ private static void boilers(List cases) { fluid(c, "getWaterTank", "minecraft:water", 0); float value = steam(c); c.require(value >= 0 && value <= 3.75F, "Three water cannot create more than 3.75 steam at the historical oil bonus (including idle loss)"); - c.require(c.nbt(X, Y, Z).getInt("BurnTime") == 0, "Small fuel aliquot burns out naturally"); }); + var boiler = (cyano.poweradvantage.api.simple.TileEntitySimplePowerMachine)c.tile(X, Y, Z); + boiler.prepareDataFieldsForSync(); + int[] counters = boiler.getDataFieldArray(); + c.require(counters != null && counters.length == 6, "Native oil boiler exposes all six public sync fields"); + c.record("oil/burnTime", counters[2]); + c.record("oil/totalBurnTime", counters[3]); + c.require(oilChargeBurnedOut(counters[2], counters[3], c.nbt(X, Y, Z)), + "Small fuel aliquot ignited and burned out naturally; public counters agree with native saved state"); }); add(cases, "geothermal-production", "A contained lava heat source boils finite water from a cold start.", 240, c -> { machine(c, "steam_boiler_geothermal"); heat(c); fill(c, "minecraft:water", 256); }, @@ -190,6 +197,12 @@ private static void boilers(List cases) { "Sub-operation electrical charge is retained without partial production"); }); } + static boolean oilChargeBurnedOut(int burnTime, int totalBurnTime, net.minecraft.nbt.CompoundTag saved) { + // The native oil boiler omits BurnTime at zero, but retains a positive completed burn total. + return burnTime == 0 && totalBurnTime > 0 && !saved.contains("BurnTime") + && saved.getInt("BurnTimeTotal").filter(total -> total == totalBurnTime).isPresent(); + } + private static void heat(MachineContext c) { for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) { c.block(X + dx, Y - 2, Z + dz, "minecraft:stone"); diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalSupplementalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalSupplementalCases.java index 8ba33d2c..acd3f417 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalSupplementalCases.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalSupplementalCases.java @@ -9,7 +9,7 @@ import net.minecraft.world.item.enchantment.Enchantment; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.ai.attributes.Attributes; -import net.minecraft.world.entity.animal.Cow; +import net.minecraft.world.entity.animal.cow.Cow; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.network.protocol.Packet; @@ -19,7 +19,7 @@ import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket; import net.minecraft.world.InteractionResult; import net.minecraft.world.InteractionHand; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.BlockPos; import net.minecraft.world.phys.Vec3; import net.minecraft.world.level.GameType; @@ -166,7 +166,7 @@ private static void prepareShot(MachineContext c, Shot shot, String id, boolean } }; shot.player.gameMode.changeGameModeForPlayer(GameType.SURVIVAL); - shot.player.moveTo(muzzle.getX() + 0.5, muzzle.getY(), muzzle.getZ() + 0.5, 0, 0); + shot.player.snapTo(muzzle.getX() + 0.5, muzzle.getY(), muzzle.getZ() + 0.5, 0, 0); shot.player.setDeltaMovement(Vec3.ZERO); shot.weapon = new ItemStack(item(c, "basesteam:musket")); shot.player.setItemInHand(InteractionHand.MAIN_HAND, shot.weapon); @@ -183,7 +183,11 @@ private static void prepareShot(MachineContext c, Shot shot, String id, boolean shot.impact = target(c, 16.5, 8.5, 16.5); c.record("impact/uuid", shot.impact.getUUID().toString()); if (collateral) { - shot.collateral = target(c, 17.1, 8.5, 16.5); + // NoAI skips travel, not native collision pushes. Keep the stationary cows disjoint. + shot.collateral = target(c, 17.5, 8.5, 16.5); + boolean overlap = shot.impact.getBoundingBox().intersects(shot.collateral.getBoundingBox()); + c.record("collateral/initialBoundsOverlap", overlap); + c.require(!overlap, "Stationary native targets do not accumulate collision impulses against each other"); c.record("collateral/uuid", shot.collateral.getUUID().toString()); } // Even a missed shot has an in-bay, nonflammable physical backstop. @@ -191,12 +195,12 @@ private static void prepareShot(MachineContext c, Shot shot, String id, boolean } private static Cow target(MachineContext c, double x, double y, double z) { - Cow target = EntityType.COW.create(c.world); + Cow target = EntityType.COW.create(c.world, net.minecraft.world.entity.EntitySpawnReason.COMMAND); c.require(target != null, "Native living target is available"); BlockPos origin = c.at(0, 0, 0); target.getAttribute(Attributes.MAX_HEALTH).setBaseValue(1000); target.setHealth(1000); - target.moveTo(origin.getX() + x, origin.getY() + y, origin.getZ() + z, 0, 0); + target.snapTo(origin.getX() + x, origin.getY() + y, origin.getZ() + z, 0, 0); target.setNoAi(true); target.setNoGravity(true); target.setPersistenceRequired(); c.require(c.world.addFreshEntity(target), "Target enters the actual server world"); return target; @@ -204,11 +208,11 @@ private static Cow target(MachineContext c, double x, double y, double z) { private static void fire(MachineContext c, Shot shot) { Item weapon = shot.weapon.getItem(); - c.require(weapon.use(c.world, shot.player, InteractionHand.MAIN_HAND).getResult() == InteractionResult.SUCCESS, + c.require(weapon.use(c.world, shot.player, InteractionHand.MAIN_HAND) == InteractionResult.SUCCESS, "Native rapid reload interaction succeeds"); c.require(shot.player.getInventory().getItem(1).getCount() == 1 - && WorksItemData.read(shot.weapon).getByte("loaded") == 1, "Exactly one finite cartridge loads the chamber"); - c.require(weapon.use(c.world, shot.player, InteractionHand.MAIN_HAND).getResult() == InteractionResult.SUCCESS + && WorksItemData.read(shot.weapon).getByte("loaded").orElseThrow() == 1, "Exactly one finite cartridge loads the chamber"); + c.require(weapon.use(c.world, shot.player, InteractionHand.MAIN_HAND) == InteractionResult.SUCCESS && shot.player.isUsingItem(), "Native use interaction enters the aiming lifecycle"); shot.player.releaseUsingItem(); } @@ -226,7 +230,7 @@ else if (explosive) c.require(shot.impact.getHealth() <= 1000 - shot.configuredD "Unenchanted damage is exactly the configured direct impact"); c.require(shot.weapon.getDamageValue() == 1 && shot.player.getInventory().getItem(1).getCount() == 1, "Each shot consumes one durability and exactly one cartridge, without duplicating debit"); - c.require(WorksItemData.read(shot.weapon).getByte("loaded") == 0, "Shot leaves an empty chamber"); + c.require(WorksItemData.read(shot.weapon).getByte("loaded").orElseThrow() == 0, "Shot leaves an empty chamber"); inside(c, shot.impact); } @@ -240,14 +244,14 @@ private static void inside(MachineContext c, Cow target) { } private static Item item(MachineContext c, String id) { - Item item = ForgeRegistries.ITEMS.getValue(ResourceLocation.parse(id)); + Item item = ForgeRegistries.ITEMS.getValue(Identifier.parse(id)); c.require(item != null && item != net.minecraft.world.item.Items.AIR, "Registered item " + id); return item; } private static void enchant(MachineContext c, ItemStack stack, String id, int level) { var enchantment = c.world.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.ENCHANTMENT) - .getOrThrow(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.ENCHANTMENT, ResourceLocation.parse(id))); + .getOrThrow(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.ENCHANTMENT, Identifier.parse(id))); c.require(level >= 1 && level <= enchantment.value().getMaxLevel(), "Registered supported enchantment " + id); stack.enchant(enchantment, level); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamReloadControlCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamReloadControlCases.java index fcc7e2e4..14756c44 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamReloadControlCases.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamReloadControlCases.java @@ -46,10 +46,10 @@ private static void crusher(List cases, String name, boolean si c.put(X, Y, Z, 0, "minecraft:stone", 1); CompoundTag tag = new CompoundTag(); tag.putShort("progress", (short)106); c.seed(X, Y, Z, tag); c.power(X, Y, Z, "steam", steam); - c.require(c.nbt(X, Y, Z).getShort("progress") == 106, "Loaded work is 106 before the first native tick"); + c.require(c.nbt(X, Y, Z).getShort("progress").orElseThrow() == 106, "Loaded work is 106 before the first native tick"); }, holding(new int[]{X}, c -> { int expected = signalled || steam == 0 ? 105 : 107; - int actual = c.nbt(X, Y, Z).getShort("progress"); + int actual = c.nbt(X, Y, Z).getShort("progress").orElseThrow(); float remaining = c.energy(X, Y, Z, "steam"); float wanted = steam == 0 ? 0 : signalled ? 39.9375F : 38.4375F; c.record("firstLoaded.progress", actual); @@ -72,9 +72,9 @@ private static void furnace(List cases, boolean signalled) { CompoundTag tag = new CompoundTag(); tag.putIntArray("Data", new int[]{Float.floatToRawIntBits(.25F), 0, 0, 10, 10, Float.floatToRawIntBits(1000)}); c.seed(X, Y, Z, tag); c.power(X, Y, Z, "steam", 40); - c.require(c.nbt(X, Y, Z).getIntArray("Data")[3] == 10, "Loaded burning state exists before any tick"); + c.require(c.nbt(X, Y, Z).getIntArray("Data").orElseThrow()[3] == 10, "Loaded burning state exists before any tick"); }, holding(new int[]{X}, c -> { - int[] data = c.nbt(X, Y, Z).getIntArray("Data"); + int[] data = c.nbt(X, Y, Z).getIntArray("Data").orElseThrow(); c.require(data.length == 6, "Furnace reports all native process fields"); float heat = Float.intBitsToFloat(data[5]), progress = Float.intBitsToFloat(data[0]); float expectedHeat = signalled ? 989.5F : 1027F; diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksBlockData.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksBlockData.java index c080f900..079d824f 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksBlockData.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksBlockData.java @@ -13,6 +13,13 @@ static CompoundTag save(BlockEntity tile) { return tile.saveWithFullMetadata(tile.getLevel().registryAccess()); } + static void load(BlockEntity tile, CompoundTag data) { + var problems = new net.minecraft.util.ProblemReporter.Collector(); + tile.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create( + problems, tile.getLevel().registryAccess(), data)); + if (!problems.isEmpty()) throw new IllegalStateException("Native block NBT could not be read: " + problems.getReport()); + } + static AABB bounds(BlockPos min, BlockPos maxExclusive) { return new AABB(Vec3.atLowerCornerOf(min), Vec3.atLowerCornerOf(maxExclusive)); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksController.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksController.java index 9e93b51f..cb3b6a83 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksController.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksController.java @@ -23,7 +23,7 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.SignBlockEntity; import net.minecraft.core.Direction; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraft.core.Registry; import net.minecraft.world.phys.AABB; import net.minecraft.core.BlockPos; @@ -38,7 +38,7 @@ import net.minecraft.tags.BiomeTags; import net.minecraftforge.common.Tags; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.event.TickEvent; + import net.minecraftforge.fml.ModList; import net.minecraftforge.event.server.ServerStoppingEvent; import net.minecraftforge.registries.ForgeRegistries; @@ -71,8 +71,8 @@ final class WorksController { private final WorldgenSamplingJob.Slot worldgenJob = new WorldgenSamplingJob.Slot(); private WorksController() { - MinecraftForge.EVENT_BUS.addListener(this::worldgenTick); - MinecraftForge.EVENT_BUS.addListener(this::worldgenStopping); + NativeTickEvents.listenServer(this::worldgenTick); + ServerStoppingEvent.BUS.addListener(this::worldgenStopping); } static WorksController getInstance() { @@ -81,7 +81,7 @@ static WorksController getInstance() { synchronized void loadDefinition() { if (definition != null) return; - definition = WorksDefinitionLoader.loadNative1211(); + definition = WorksDefinitionLoader.loadNative12111(); for (WorksDefinition.Station station : definition.stations) { stations.put(station.id.toUpperCase(Locale.ROOT), station); statuses.put(station.id, "NOT_BUILT"); @@ -113,7 +113,8 @@ synchronized String build(MinecraftServer server, String target) throws IllegalS statuses.put(station.id, "READY"); built++; } - world.setDefaultSpawnPos(new BlockPos(0, 4, 0), 0.0F); + world.setRespawnData(net.minecraft.world.level.storage.LevelData.RespawnData.of( + world.dimension(), new BlockPos(0, 4, 0), 0.0F, 0.0F)); return "Advantage Works built " + built + " station(s); skipped " + skipped + " for missing mods."; } @@ -216,14 +217,14 @@ synchronized String sampleWorldgen(MinecraftServer server, int radius) throws Il } if (worldgenJob.current() != null) throw commandError("A worldgen sample is already running"); ServerLevel world = overworld(server); - BlockPos spawn = world.getSharedSpawnPos(); + BlockPos spawn = world.getRespawnData().pos(); worldgenJob.start(new WorldgenSamplingJob(new Native117WorldgenAccess(world), spawn.getX(), spawn.getY(), spawn.getZ(), world.getSeaLevel(), radius)); return "Queued worldgen sample with radius " + radius + " for bounded server-tick sampling"; } - private synchronized void worldgenTick(TickEvent.ServerTickEvent event) { - if (event.phase != TickEvent.Phase.END || worldgenJob.current() == null) return; + private synchronized void worldgenTick(NativeTickEvents.ServerTickEvent event) { + if (event.phase() != NativeTickEvents.Phase.END || worldgenJob.current() == null) return; WorldgenSamplingJob job = worldgenJob.current(); job.tick(); if (!job.finished()) return; @@ -349,7 +350,7 @@ private List> snapshot(ServerLevel world, WorksDefinition.St for (BlockPos pos : positions) { Map block = new LinkedHashMap<>(); BlockState state = world.getBlockState(pos); - ResourceLocation name = ForgeRegistries.BLOCKS.getKey(state.getBlock()); + Identifier name = ForgeRegistries.BLOCKS.getKey(state.getBlock()); block.put("pos", Arrays.asList(pos.getX(), pos.getY(), pos.getZ())); block.put("block", name == null ? "unknown" : name.toString()); block.put("state", state.toString()); @@ -416,7 +417,7 @@ private boolean evaluateAssertion(ServerLevel world, WorksDefinition.Station sta if ("comparatorOutput".equals(assertion.type)) { BlockState state = world.getBlockState(pos); return state.hasAnalogOutputSignal() - && compare(state.getAnalogOutputSignal(world, pos), + && compare(state.getAnalogOutputSignal(world, pos, Direction.UP), assertion.operator, assertion.value); } BlockEntity tile = world.getBlockEntity(pos); @@ -451,7 +452,7 @@ && compare(state.getAnalogOutputSignal(world, pos), } if ("energy".equals(assertion.type)) { if (tile == null) return false; - int[] energy = WorksBlockData.save(tile).getIntArray("Energy"); + int[] energy = WorksBlockData.save(tile).getIntArray("Energy").orElseThrow(); int index = assertion.energyIndex == null ? 0 : assertion.energyIndex; return index >= 0 && index < energy.length && compare(Float.intBitsToFloat(energy[index]), assertion.operator, assertion.value); @@ -475,12 +476,12 @@ private static Number numberAt(CompoundTag root, String path) { String[] parts = path.split("\\."); CompoundTag current = root; for (int i = 0; i < parts.length - 1; i++) { - if (!current.contains(parts[i], 10)) return null; - current = current.getCompound(parts[i]); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(current, parts[i], 10)) return null; + current = current.getCompound(parts[i]).orElseThrow(); } String leaf = parts[parts.length - 1]; - if (!current.contains(leaf, 99)) return null; - return current.getDouble(leaf); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(current, leaf, 99)) return null; + return current.getDouble(leaf).orElseThrow(); } private static String stringAt(CompoundTag root, String path) { @@ -488,11 +489,11 @@ private static String stringAt(CompoundTag root, String path) { String[] parts = path.split("\\."); CompoundTag current = root; for (int i = 0; i < parts.length - 1; i++) { - if (!current.contains(parts[i], 10)) return null; - current = current.getCompound(parts[i]); + if (!zone.moddev.mc.basepower.util.NbtCompat.containsTag(current, parts[i], 10)) return null; + current = current.getCompound(parts[i]).orElseThrow(); } String leaf = parts[parts.length - 1]; - return current.contains(leaf, 8) ? current.getString(leaf) : null; + return zone.moddev.mc.basepower.util.NbtCompat.containsTag(current, leaf, 8) ? current.getString(leaf).orElseThrow() : null; } private static boolean compare(double actual, String operator, Number expected) { @@ -635,7 +636,7 @@ private void applyActions(ServerLevel world, WorksDefinition.Station station, if (action.enchantment != null) { var enchantment = world.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.ENCHANTMENT) .getOrThrow(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.ENCHANTMENT, - ResourceLocation.parse(action.enchantment))); + Identifier.parse(action.enchantment))); stack.enchant(enchantment, action.level); } ((Container) tile).setItem(action.slot, stack); @@ -700,8 +701,8 @@ private void applyActions(ServerLevel world, WorksDefinition.Station station, world.addFreshEntity(entityItem); break; case "spawnEntity": - EntityType entityType = ForgeRegistries.ENTITY_TYPES.getValue(ResourceLocation.parse(action.entity)); - Entity entity = entityType == null ? null : entityType.create(world); + EntityType entityType = ForgeRegistries.ENTITY_TYPES.getValue(Identifier.parse(action.entity)); + Entity entity = entityType == null ? null : entityType.create(world, net.minecraft.world.entity.EntitySpawnReason.COMMAND); if (entity == null) throw new IllegalStateException("Unknown entity " + action.entity); entity.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5); tagEntity(entity, station.id); @@ -751,7 +752,7 @@ private void mergeTileNbt(ServerLevel world, BlockPos pos, JsonObject supplied) base.putInt("x", pos.getX()); base.putInt("y", pos.getY()); base.putInt("z", pos.getZ()); - tile.loadWithComponents(base, world.registryAccess()); + WorksBlockData.load(tile, base); tile.setChanged(); } catch (Exception ex) { throw new IllegalStateException("Cannot merge tile NBT at " + pos, ex); @@ -820,7 +821,7 @@ private static void placeCommandSign(ServerLevel world, BlockPos pos, String lab if (!(tile instanceof SignBlockEntity)) return; SignBlockEntity sign = (SignBlockEntity) tile; net.minecraft.network.chat.MutableComponent clickable = Component.literal(label); - clickable.setStyle(Style.EMPTY.withClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, command))); + clickable.setStyle(Style.EMPTY.withClickEvent(new ClickEvent.RunCommand(command))); sign.setText(sign.getFrontText().setMessage(0, clickable) .setMessage(1, Component.literal("right-click")), true); sign.setWaxed(true); @@ -862,7 +863,7 @@ private static Block block(String name) { if (name == null) return Blocks.AIR; String resolved = "minecraft:flowing_water".equals(name) ? "minecraft:water" : "minecraft:stonebrick".equals(name) ? "minecraft:stone_bricks" : name; - Block value = ForgeRegistries.BLOCKS.getValue(ResourceLocation.parse(resolved)); + Block value = ForgeRegistries.BLOCKS.getValue(Identifier.parse(resolved)); return value == null ? Blocks.AIR : value; } @@ -870,7 +871,7 @@ private static Item item(String name) { if (name == null) return null; String resolved = "minecraft:sapling".equals(name) ? "minecraft:oak_sapling" : "minecraft:planks".equals(name) ? "minecraft:oak_planks" : name; - ResourceLocation key = ResourceLocation.parse(resolved); + Identifier key = Identifier.parse(resolved); return ForgeRegistries.ITEMS.containsKey(key) ? ForgeRegistries.ITEMS.getValue(key) : null; } @@ -881,7 +882,7 @@ private static IllegalStateException commandError(String message) { private static BlockState legacyState(String name, Block block, int metadata) { if (name != null && (name.startsWith("basepower:") || name.startsWith("basesteam:") || name.startsWith("baseelectric:"))) { - return LegacyMigrationApi.decodeBlockState(ResourceLocation.parse(name), block, metadata); + return LegacyMigrationApi.decodeBlockState(Identifier.parse(name), block, metadata); } if (block instanceof LiquidBlock) { return block.defaultBlockState().setValue(LiquidBlock.LEVEL, Integer.valueOf(metadata & 15)); @@ -897,17 +898,17 @@ private static ItemStack itemStack(Item item, int count, int damage) { private static Fluid fluid(String name) { if (name == null || name.trim().isEmpty()) return null; - ResourceLocation id = name.indexOf(':') >= 0 ? ResourceLocation.parse(name) - : ResourceLocation.fromNamespaceAndPath("basepower", name); + Identifier id = name.indexOf(':') >= 0 ? Identifier.parse(name) + : Identifier.fromNamespaceAndPath("basepower", name); Fluid value = net.minecraft.core.registries.BuiltInRegistries.FLUID.getOptional(id).orElse(null); if (value == null) { String migratedName = LegacyMigrationApi.fluidTarget(name); if (!name.equals(migratedName)) { - value = net.minecraft.core.registries.BuiltInRegistries.FLUID.getOptional(ResourceLocation.parse(migratedName)).orElse(null); + value = net.minecraft.core.registries.BuiltInRegistries.FLUID.getOptional(Identifier.parse(migratedName)).orElse(null); } } if (value == null && name.indexOf(':') < 0) { - value = net.minecraft.core.registries.BuiltInRegistries.FLUID.getOptional(ResourceLocation.fromNamespaceAndPath("minecraft", name)).orElse(null); + value = net.minecraft.core.registries.BuiltInRegistries.FLUID.getOptional(Identifier.fromNamespaceAndPath("minecraft", name)).orElse(null); } return value; } @@ -915,6 +916,6 @@ private static Fluid fluid(String name) { private static Block wool(int metadata) { String[] colors = {"white", "orange", "magenta", "light_blue", "yellow", "lime", "pink", "gray", "light_gray", "cyan", "purple", "blue", "brown", "green", "red", "black"}; - return ForgeRegistries.BLOCKS.getValue(ResourceLocation.fromNamespaceAndPath("minecraft", colors[metadata & 15] + "_wool")); + return ForgeRegistries.BLOCKS.getValue(Identifier.fromNamespaceAndPath("minecraft", colors[metadata & 15] + "_wool")); } } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksDefinitionLoader.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksDefinitionLoader.java index 0d6f3ddf..6ac3451a 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksDefinitionLoader.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksDefinitionLoader.java @@ -3,7 +3,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import java.io.InputStream; import java.io.InputStreamReader; @@ -50,6 +50,10 @@ static WorksDefinition loadNative1211() { return load("/assets/advantageworkstest/works/works-1.21.1.json", "1.21.1"); } + static WorksDefinition loadNative12111() { + return load("/assets/advantageworkstest/works/works-1.21.11.json", "1.21.11"); + } + static WorksDefinition load(String resource, String minecraft) { JsonObject target = readResource(resource); if (target.get("format").getAsInt() != 1 || !minecraft.equals(target.get("minecraft").getAsString()) @@ -260,7 +264,7 @@ private static void validateNbt(com.google.gson.JsonObject nbt, String label) { private static void requireResource(String value, String label) { requireId(value, label); - ResourceLocation.parse(value); + Identifier.parse(value); } private static void validatePosition(WorksDefinition.Station station, int[] pos, String label) { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksOfflinePlayer.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksOfflinePlayer.java index db7ef761..6fae7340 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksOfflinePlayer.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksOfflinePlayer.java @@ -2,7 +2,6 @@ import com.mojang.authlib.GameProfile; import java.util.UUID; -import net.minecraft.network.Connection; import net.minecraft.network.protocol.Packet; import net.minecraft.network.protocol.PacketFlow; import net.minecraft.server.level.ClientInformation; @@ -11,7 +10,7 @@ import net.minecraft.server.network.CommonListenerCookie; import net.minecraft.server.network.ServerGamePacketListenerImpl; -/** Forge 50 no longer ships FakePlayer. These actors never join the player list. */ +/** Offline actors never join the server player list or establish a socket. */ class WorksOfflinePlayer extends ServerPlayer { private static final GameProfile SENTINEL = new GameProfile( UUID.fromString("ec003652-141d-4c8d-b575-25381335af83"), "AdvWorksSentinel"); @@ -19,8 +18,9 @@ class WorksOfflinePlayer extends ServerPlayer { WorksOfflinePlayer(ServerLevel level, GameProfile profile) { super(level.getServer(), level, profile, ClientInformation.createDefault()); connection = new ServerGamePacketListenerImpl(level.getServer(), - new Connection(PacketFlow.SERVERBOUND), this, cookie(this)) { + new net.minecraft.network.Connection(PacketFlow.SERVERBOUND), this, cookie(this)) { @Override public void send(Packet packet) {} + @Override public void send(Packet packet, io.netty.channel.ChannelFutureListener listener) {} }; } @@ -30,7 +30,7 @@ static CommonListenerCookie cookie(ServerPlayer player) { static WorksOfflinePlayer sentinel(ServerLevel level) { for (ServerPlayer player : level.players()) { - if (player instanceof WorksOfflinePlayer actor && actor.getUUID().equals(SENTINEL.getId())) return actor; + if (player instanceof WorksOfflinePlayer actor && actor.getUUID().equals(SENTINEL.id())) return actor; } return new WorksOfflinePlayer(level, SENTINEL); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksResultWriter.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksResultWriter.java index ccc9f844..bb2f8841 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksResultWriter.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksResultWriter.java @@ -31,7 +31,7 @@ File write(String stationId, String kind, Map payload) { Map document = new LinkedHashMap<>(); document.put("format", 1); document.put("recordedAtUtc", utcNow()); - document.put("minecraft", net.minecraft.SharedConstants.getCurrentVersion().getName()); + document.put("minecraft", net.minecraft.SharedConstants.getCurrentVersion().name()); document.put("forge", net.minecraftforge.versions.forge.ForgeVersion.getVersion()); document.put("mods", loadedMods()); document.put("station", stationId); diff --git a/test-rig/src/main/resources/META-INF/mods.toml b/test-rig/src/main/resources/META-INF/mods.toml index 0fc5e5f5..76f8878b 100644 --- a/test-rig/src/main/resources/META-INF/mods.toml +++ b/test-rig/src/main/resources/META-INF/mods.toml @@ -1,5 +1,5 @@ modLoader="javafml" -loaderVersion="[52,53)" +loaderVersion="[61,62)" license="LGPL-3.0-only" [[mods]] @@ -7,25 +7,26 @@ modId="advantageworkstest" version="${version}" displayName="Advantage Works Test Harness" authors="Minecraft Mod Development" +showAsResourcePack=false description='''Server-side commissioning harness for the Advantage family. Not for distribution.''' [[dependencies.advantageworkstest]] modId="basepower" mandatory=true -versionRange="[2.4.14.121011,3.0.0)" +versionRange="[2.4.15.121111,3.0.0)" ordering="AFTER" side="SERVER" [[dependencies.advantageworkstest]] modId="forge" mandatory=true -versionRange="[52.1.0,53)" +versionRange="[61.1.0,62)" ordering="NONE" side="SERVER" [[dependencies.advantageworkstest]] modId="minecraft" mandatory=true -versionRange="[1.21.1]" +versionRange="[1.21.11]" ordering="NONE" side="SERVER" diff --git a/test-rig/src/main/resources/assets/advantageworkstest/native12111-helper-contract.json b/test-rig/src/main/resources/assets/advantageworkstest/native12111-helper-contract.json new file mode 100644 index 00000000..de8927ee --- /dev/null +++ b/test-rig/src/main/resources/assets/advantageworkstest/native12111-helper-contract.json @@ -0,0 +1,54 @@ +{ + "format": 1, + "policy": "native12111-leash-knot-nosave-v1", + "minecraftVersion": "1.21.11", + "dataVersion": 4671, + "forgeVersion": "61.1.0", + "nativeClass": "net.minecraft.world.entity.decoration.LeashFenceKnotEntity", + "registryId": "minecraft:leash_knot", + "canSerialize": false, + "storageSelectorAccepts": true, + "encodedIdPresent": false, + "rootWriterAccepted": false, + "passengerWriterAccepted": false, + "refusedWritersLeaveNoPayload": true, + "observationalUuidPreserved": true, + "observationalStateUnchanged": true, + "observationalTagKeys": [ + "Air", + "CanUpdate", + "Fire", + "Invulnerable", + "Motion", + "OnGround", + "PortalCooldown", + "Pos", + "Rotation", + "UUID", + "fall_distance" + ], + "noPower": true, + "powerAccessTransformerAbsent": true, + "noHarness": true, + "noServer": true, + "noTicks": true, + "nativeMethodsUnmodified": true, + "nativeClassSha256": { + "net.minecraft.world.entity.Entity": "55C9946FA24C5D05FD2F754AEECCB9C69EC0E5B0BC09DD265BBEAE2BB1461091", + "net.minecraft.world.entity.EntityType": "2FA5F425EB25F5257FD17421B4EC0F5B87BCB0DE244BA98634A80ADBA53E41D0", + "net.minecraft.world.entity.decoration.LeashFenceKnotEntity": "1D534C5B0845C42E042E23494A73253F21CED1C01D7AA9930730B991852AA9F2", + "net.minecraft.util.datafix.fixes.BlockStateData": "03A5DA5E8D91C978D792656FD5562F09D1B78A83CB3746112480AAB233E0976B" + }, + "engineJarSha256": "A83D5BCBCC07CD7A0E7D973FD900F039D677C8E880DB63DC08509C0B009C16A6", + "engineSourcesSha256": "DFE912C3B1E769B60DA2B4C5EA0B7F9171637B26DA640A9D85DE8CA683B30661", + "nativeSourceSha256": { + "net/minecraft/world/entity/Entity.java": "E9F5AE3DE2F51D5ABC60F6FCE4950FD548AD8CF55CF4BBC6925F57BF7F5ED5D2", + "net/minecraft/world/entity/EntityType.java": "CA2885F8B4E9556332172F863CC9594891DBB49FCBE7DEE918100560B7696724", + "net/minecraft/world/entity/decoration/LeashFenceKnotEntity.java": "DAE14BA782CCF1F08685B2561779B3423DB105FF58F916BA93185E76988D09C0", + "net/minecraft/util/datafix/fixes/BlockStateData.java": "2B8F902EF929BB863F1C25C4FF904EC02CD663A12AA90FF0ED34523A2EC14EE1" + }, + "controlSourceSha256": { + "NativePlatformSupport.java": "2DA38F0F6D85CB8C4E067F7AA98750B9E85C9C63B5873CC591E3579FEBF1706D", + "NativeLeashKnotPlatformControl.java": "58CE7BD8BE56AC0376E738AB8871BC9D8BF72969A6319FA37CAACFFC91567D36" + } +} diff --git a/test-rig/src/main/resources/assets/advantageworkstest/works/works-1.21.11.json b/test-rig/src/main/resources/assets/advantageworkstest/works/works-1.21.11.json new file mode 100644 index 00000000..4d326d6d --- /dev/null +++ b/test-rig/src/main/resources/assets/advantageworkstest/works/works-1.21.11.json @@ -0,0 +1,6 @@ +{ + "format": 1, + "minecraft": "1.21.11", + "title": "Advantage Works 1.21.11 Commissioning Ground", + "stationSource": "/assets/advantageworkstest/works/works-1.13.2.json" +} diff --git a/test-rig/src/main/resources/pack.mcmeta b/test-rig/src/main/resources/pack.mcmeta index a84f888e..50ccec54 100644 --- a/test-rig/src/main/resources/pack.mcmeta +++ b/test-rig/src/main/resources/pack.mcmeta @@ -1,8 +1,7 @@ { "pack": { - "description": "Advantage Works 1.20.6 test resources", - "pack_format": 32, - "forge:resource_pack_format": 32, - "forge:data_pack_format": 41 + "description": "Advantage Works 1.21.11 test resources", + "min_format": [94, 1], + "max_format": [94, 1] } } diff --git a/test-rig/src/runner/java/zone/moddev/mc/advantageworks/runner/WorksRunner.java b/test-rig/src/runner/java/zone/moddev/mc/advantageworks/runner/WorksRunner.java index 8507740a..f4a8908f 100644 --- a/test-rig/src/runner/java/zone/moddev/mc/advantageworks/runner/WorksRunner.java +++ b/test-rig/src/runner/java/zone/moddev/mc/advantageworks/runner/WorksRunner.java @@ -67,7 +67,7 @@ private WorksRunner() { public static void main(String[] args) throws Exception { Arguments arguments = Arguments.parse(args); - Profile profile = readNative1211Profile(arguments.profile); + Profile profile = readRunnerProfile(arguments.profile); if (arguments.serverPort != null) profile.serverPort = arguments.serverPort; File testRig = findTestRig(arguments.profile); File powerRoot = testRig.getParentFile().getCanonicalFile(); @@ -89,7 +89,8 @@ public static void main(String[] args) throws Exception { File runtime = new File(runtimeBase, profile.name + "-" + stamp).getCanonicalFile(); requireChild(new File(testRig, "build").getCanonicalFile(), runtime); if (profile.preparedSeed == null) { - throw new IllegalArgumentException("Forge 52 runtime requires an official prepared server and sealed inventory"); + throw new IllegalArgumentException(("1.21.11".equals(profile.minecraftVersion) ? "Forge 61" : "Forge 52") + + " runtime requires an official prepared server and sealed inventory"); } if (profile.preparedSeed != null) profile.preparedSeed.requireDisjoint(runtime); if (!runtime.mkdirs()) throw new IllegalStateException("Cannot create runtime " + runtime); @@ -319,15 +320,18 @@ static void requireModularLauncher(File runtime, String launcher) throws Excepti PreparedServerSeed.requirePlain(root); Path args = root.resolve(launcher); PreparedServerSeed.requirePlain(args); + boolean forge61 = launcher.startsWith("libraries/net/minecraftforge/forge/1.21.11-61.1.0/"); boolean forge52 = launcher.startsWith("libraries/net/minecraftforge/forge/1.21.1-52.1.0/"); - if (forge52 || launcher.startsWith("libraries/net/minecraftforge/forge/1.20.6-50.2.0/")) { - String shim = forge52 ? "forge-1.21.1-52.1.0-shim.jar" : "forge-1.20.6-50.2.0-shim.jar"; + if (forge61 || forge52 || launcher.startsWith("libraries/net/minecraftforge/forge/1.20.6-50.2.0/")) { + String shim = forge61 ? "forge-1.21.11-61.1.0-shim.jar" + : forge52 ? "forge-1.21.1-52.1.0-shim.jar" : "forge-1.20.6-50.2.0-shim.jar"; Path jar = root.resolve(shim); PreparedServerSeed.requirePlain(jar); if (!Files.readString(args, StandardCharsets.UTF_8).strip().equals( "-Djava.net.preferIPv6Addresses=system -jar " + shim) - || !sha256(jar.toFile()).equals(forge52 - ? "F7B706C79896728723DA0E4E8C5A6ECDB6A617AD4DF9D7EA1DDC6367187BC291" + || !sha256(jar.toFile()).equals(forge61 + ? "09008DB9881E35E62669865F3B7420741EDB1A52BC59C67A5FA03D24C6C8E6E3" + : forge52 ? "F7B706C79896728723DA0E4E8C5A6ECDB6A617AD4DF9D7EA1DDC6367187BC291" : "7BECDDA6D36C8359EADA6018C2EDB21C5914C50028C191BE09A39FF9F516E10C")) { throw new IllegalArgumentException("Unexpected official Forge shim launcher"); } @@ -807,7 +811,9 @@ static PreparedServerSeed preparedServerSeed(File testRig, Profile profile, Argu } return new PreparedServerSeed(preparedPath(testRig, arguments.preparedServerSeed), preparedPath(testRig, arguments.preparedServerSeedInventory), arguments.preparedServerSeedSha256, - serverLauncherPath(profile), "1.21.1".equals(profile.minecraftVersion) + serverLauncherPath(profile), "1.21.11".equals(profile.minecraftVersion) + ? "forge-1.21.11-61.1.0-shim.jar" + : "1.21.1".equals(profile.minecraftVersion) ? "forge-1.21.1-52.1.0-shim.jar" : "1.20.6".equals(profile.minecraftVersion) ? "forge-1.20.6-50.2.0-shim.jar" @@ -858,12 +864,13 @@ static final class PreparedServerSeed { final Map files = new LinkedHashMap<>(); final Set directories = new LinkedHashSet<>(); final List processes = new ArrayList<>(); - final boolean forge50, forge52; + final boolean forge50, forge52, forge61; PreparedServerSeed(Path root, Path inventory, String expectedHash, String launcher, String minecraft) throws Exception { forge50 = "forge-1.20.6-50.2.0-shim.jar".equals(minecraft); forge52 = "forge-1.21.1-52.1.0-shim.jar".equals(minecraft); + forge61 = "forge-1.21.11-61.1.0-shim.jar".equals(minecraft); this.root = root.toAbsolutePath().normalize(); this.inventory = inventory.toAbsolutePath().normalize(); if (expectedHash == null || !expectedHash.matches("[0-9A-F]{64}")) { @@ -931,6 +938,9 @@ static final class PreparedServerSeed { } } reader.endObject(); + if (forge61 && (!reader.hasNext() || !"forge".equals(reader.nextName()) + || !"1.21.11-61.1.0".equals(reader.nextString()))) + throw new IllegalArgumentException("Incorrect Forge 61 shim inventory metadata"); if ((forge50 || forge52) && reader.hasNext() && (!"forge".equals(reader.nextName()) || !(forge52 ? "1.21.1-52.1.0" : "1.20.6-50.2.0").equals(reader.nextString()))) throw new IllegalArgumentException("Incorrect Forge shim inventory metadata"); @@ -986,8 +996,9 @@ void verifyTree(Path location, boolean codeOnly) throws Exception { catch (Exception failure) { throw new IOException(failure); } return FileVisitResult.CONTINUE; } - if (codeOnly && (forge50 || forge52) && Set.of("README.txt", "run.bat", "run.sh", "user_jvm_args.txt", - forge52 ? "forge-1.21.1-52.1.0-installer.jar.log" : "forge-1.20.6-50.2.0-installer.jar.log").contains(relative)) { + if (codeOnly && (forge50 || forge52 || forge61) && Set.of("README.txt", "run.bat", "run.sh", "user_jvm_args.txt", + forge61 ? "forge-1.21.11-61.1.0-installer.jar.log" + : forge52 ? "forge-1.21.1-52.1.0-installer.jar.log" : "forge-1.20.6-50.2.0-installer.jar.log").contains(relative)) { requirePlain(path); return FileVisitResult.CONTINUE; } @@ -1141,12 +1152,14 @@ static String serverLauncherPath(Profile profile) { private static boolean modularForge(Profile profile) { return "1.17.1".equals(profile.minecraftVersion) || "1.18.2".equals(profile.minecraftVersion) || "1.20.1".equals(profile.minecraftVersion) || "1.20.6".equals(profile.minecraftVersion) - || "1.21.1".equals(profile.minecraftVersion); + || "1.21.1".equals(profile.minecraftVersion) || "1.21.11".equals(profile.minecraftVersion); } private static File runtimeJavaHome(Profile profile) throws Exception { if (!modularForge(profile)) return java8Home(profile); - if ("1.20.6".equals(profile.minecraftVersion) || "1.21.1".equals(profile.minecraftVersion)) { + if ("1.21.11".equals(profile.minecraftVersion)) requireNative12111Environment(System.getenv()); + if ("1.20.6".equals(profile.minecraftVersion) || "1.21.1".equals(profile.minecraftVersion) + || "1.21.11".equals(profile.minecraftVersion)) { String configured = profile.java21Home; if (configured == null || configured.isBlank()) configured = System.getenv("ADVANTAGE_WORKS_JAVA21_HOME"); File home = new File(configured == null || configured.isBlank() @@ -1180,6 +1193,15 @@ private static File runtimeJavaHome(Profile profile) throws Exception { return home.getCanonicalFile(); } + static void requireNative12111Environment(Map environment) { + for (String variable : Arrays.asList("JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS")) { + String value = environment.get(variable); + if (value != null && !value.isBlank()) { + throw new IllegalArgumentException("Forge 61 cannot inherit JVM options or agents from " + variable); + } + } + } + private static File java8Home(Profile profile) throws Exception { String configured = profile.java8Home; if (configured == null || configured.trim().isEmpty()) { @@ -1268,6 +1290,9 @@ static void writeRuntimeFiles(File runtime, Profile profile) throws Exception { + "spawn-npcs=false\n" + "spawn-protection=0\n" + "view-distance=10\n"; + if ("1.21.11".equals(profile.minecraftVersion)) { + properties += "pause-when-empty-seconds=-1\n"; + } write(new File(runtime, "server.properties"), properties); } @@ -1342,6 +1367,25 @@ static Profile readNative1211Profile(File file) throws Exception { return readNativeShimProfile(file, "1.21.1", "1.21.1-52.1.0", ".121011"); } + static Profile readNative12111Profile(File file) throws Exception { + Profile profile = readNativeShimProfile(file, "1.21.11", "1.21.11-61.1.0", ".121111"); + if (!"build/works-native12111".equals(profile.runtimeBase)) { + throw new IllegalArgumentException("Forge 61 must use its isolated build/works-native12111 runtime base"); + } + return profile; + } + + static Profile readRunnerProfile(File file) throws Exception { + try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) { + JsonObject metadata = GSON.fromJson(reader, JsonObject.class); + if (metadata != null && metadata.has("minecraftVersion") + && "1.21.11".equals(metadata.get("minecraftVersion").getAsString())) { + return readNative12111Profile(file); + } + } + return readNative1211Profile(file); + } + private static Profile readNativeShimProfile(File file, String minecraft, String forge, String suffix) throws Exception { Profile profile = readProfile(file, minecraft, forge); if (!"EXPLORATORY_UNQUALIFIED".equals(profile.qualificationStatus) || !"packaged".equals(profile.mode)) { diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/ConnectedProbeObservationTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/ConnectedProbeObservationTest.java index 3d3c943b..ffeadae2 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/ConnectedProbeObservationTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/ConnectedProbeObservationTest.java @@ -10,7 +10,6 @@ import java.util.List; import net.minecraft.SharedConstants; import net.minecraft.core.BlockPos; -import net.minecraft.core.HolderLookup; import net.minecraft.core.RegistryAccess; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtIo; @@ -21,6 +20,7 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.ChestBlockEntity; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueOutput; import org.junit.BeforeClass; import org.junit.Test; @@ -71,7 +71,7 @@ private static void observe(CompoundTag data) throws Exception { assertEquals(before.toString(), row.get("nbt").getAsString()); assertEquals(before, NbtIo.readCompressed(new ByteArrayInputStream( Base64.getDecoder().decode(row.get("nbtBase64").getAsString())), NbtAccounter.unlimitedHeap())); - assertEquals(data == null ? "" : data.getString("AdvConnectedWitness"), row.get("witness").getAsString()); + assertEquals(data == null ? "" : data.getStringOr("AdvConnectedWitness", ""), row.get("witness").getAsString()); assertEquals(data != null, before.contains("ForgeData")); } } @@ -92,17 +92,17 @@ public CompoundTag getPersistentData() { if (persistentData == null) persistentData = new CompoundTag(); return persistentData; } - @Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { - super.saveAdditional(tag, registries); + @Override protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); // Forge 50 no longer stores opaque ForgeData for vanilla tiles. // This extension fixture proves the probe observes, but never allocates, the legacy witness. - if (persistentData != null) tag.put("ForgeData", persistentData.copy()); + if (persistentData != null) output.store("ForgeData", CompoundTag.CODEC, persistentData.copy()); } } private static final class ObservationWorld extends ServerLevel { BlockEntity tile; - private ObservationWorld() { super(null, null, null, null, null, null, null, false, 0L, List.of(), false, null); } + private ObservationWorld() { super(null, null, null, null, null, null, false, 0L, List.of(), false, null); } @Override public BlockState getBlockState(BlockPos pos) { return Blocks.CHEST.defaultBlockState(); } @Override public BlockEntity getBlockEntity(BlockPos pos) { return tile; } @Override public RegistryAccess registryAccess() { diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/DimensionEntityProbeTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/DimensionEntityProbeTest.java new file mode 100644 index 00000000..9f301372 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/DimensionEntityProbeTest.java @@ -0,0 +1,97 @@ +package zone.moddev.mc.advantageworks; + +import static org.junit.Assert.*; + +import com.mojang.authlib.GameProfile; +import java.lang.reflect.Field; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.LevelData; +import net.minecraft.world.level.storage.TagValueOutput; +import org.junit.BeforeClass; +import org.junit.Test; + +public class DimensionEntityProbeTest { + @BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + } + + @Test public void diagnosticsObserveNativeRespawnCodecWithoutInventingLegacyFields() throws Exception { + for (boolean forced : new boolean[] {false, true}) { + ProbePlayer player = player(forced); + CompoundTag saved = saved(player); + var result = DimensionEntityProbe.spawnDiagnostics(player, saved); + assertEquals(-1, result.get("playerWorldDimension").getAsInt()); + assertEquals(0, result.get("spawnDimension").getAsInt()); + assertEquals(forced, result.get("spawnForced").getAsBoolean()); + var spawn = result.getAsJsonObject("serializedSpawn"); + assertEquals("minecraft:overworld", spawn.get("dimension").getAsString()); + assertEquals(160, spawn.getAsJsonArray("pos").get(0).getAsInt()); + assertEquals(100, spawn.getAsJsonArray("pos").get(1).getAsInt()); + assertEquals(24, spawn.getAsJsonArray("pos").get(2).getAsInt()); + assertEquals(15.0F, spawn.get("yaw").getAsFloat(), 0.0F); + assertEquals(-5.0F, spawn.get("pitch").getAsFloat(), 0.0F); + assertEquals(forced, spawn.has("forced")); + assertFalse(spawn.has("SpawnX")); + assertEquals(saved.getCompound("respawn").orElseThrow(), net.minecraft.nbt.NbtIo.readCompressed( + new java.io.ByteArrayInputStream(java.util.Base64.getDecoder().decode( + result.getAsJsonObject("serializedRespawn").get("nbtBase64").getAsString())), + net.minecraft.nbt.NbtAccounter.unlimitedHeap())); + } + } + + @Test public void missingOrMalformedSavedRespawnCannotBecomeFalseGreen() throws Exception { + ProbePlayer player = player(true); + assertThrows(IllegalStateException.class, () -> DimensionEntityProbe.spawnDiagnostics(player, new CompoundTag())); + CompoundTag malformed = saved(player); + malformed.getCompound("respawn").orElseThrow().putString("pos", "not a block position"); + assertThrows(IllegalStateException.class, () -> DimensionEntityProbe.spawnDiagnostics(player, malformed)); + } + + @Test public void savedRespawnMustMatchTheNativeConfiguration() throws Exception { + ProbePlayer player = player(true); + CompoundTag mismatched = saved(player(false)); + assertThrows(IllegalStateException.class, () -> DimensionEntityProbe.spawnDiagnostics(player, mismatched)); + player.config = null; + assertThrows(IllegalStateException.class, () -> DimensionEntityProbe.spawnDiagnostics(player, mismatched)); + var empty = DimensionEntityProbe.spawnDiagnostics(player, new CompoundTag()); + assertTrue(empty.get("spawnForced").isJsonNull()); + assertTrue(empty.get("bedLocation").isJsonNull()); + assertTrue(empty.get("serializedRespawn").isJsonNull()); + } + + private static CompoundTag saved(ProbePlayer player) { + TagValueOutput output = TagValueOutput.createWithContext(NativeActorIo.reporter("respawn test"), player.registryAccess()); + output.store("respawn", ServerPlayer.RespawnConfig.CODEC, player.config); + return output.buildResult(); + } + + private static ProbePlayer player(boolean forced) throws Exception { + Class unsafe = Class.forName("sun.misc.Unsafe"); + Field field = unsafe.getDeclaredField("theUnsafe"); + field.setAccessible(true); + ProbePlayer player = (ProbePlayer) unsafe.getMethod("allocateInstance", Class.class).invoke(field.get(null), ProbePlayer.class); + player.world = Native117EntityContextTest.world(Level.NETHER); + player.config = new ServerPlayer.RespawnConfig( + LevelData.RespawnData.of(Level.OVERWORLD, new BlockPos(160, 100, 24), 15.0F, -5.0F), forced); + return player; + } + + private static final class ProbePlayer extends ServerPlayer { + ServerLevel world; + RespawnConfig config; + private ProbePlayer() { + super(null, null, new GameProfile(UUID.randomUUID(), "RespawnControl"), + net.minecraft.server.level.ClientInformation.createDefault()); + } + @Override public ServerLevel level() { return world; } + @Override public RegistryAccess registryAccess() { return world.registryAccess(); } + @Override public RespawnConfig getRespawnConfig() { return config; } + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117AsyncWorldgenSourceTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117AsyncWorldgenSourceTest.java index e774e4c3..e27591d9 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117AsyncWorldgenSourceTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117AsyncWorldgenSourceTest.java @@ -5,11 +5,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.TicketType; -import net.minecraft.world.level.ChunkPos; +import org.junit.BeforeClass; import org.junit.Test; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; @@ -18,10 +17,20 @@ import static org.junit.Assert.*; public class Native117AsyncWorldgenSourceTest { + @BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + } + @Test public void actualNativeUnknownTicketExpiresButDedicatedRegionTicketDoesNot() { assertEquals(1L, TicketType.UNKNOWN.timeout()); - TicketType retained = TicketType.create("async-worldgen-unit", Comparator.comparingLong(ChunkPos::toLong)); + assertTrue(TicketType.UNKNOWN.hasTimeout()); + TicketType retained = new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING); assertEquals(0L, retained.timeout()); + assertFalse(retained.hasTimeout()); + assertTrue(retained.doesLoad()); + assertFalse(retained.doesSimulate()); + assertFalse(retained.canExpireIfUnloaded()); } @Test public void actualMappedNativeFutureApiHasBothBlockingAndOffThreadBranches() throws Exception { @@ -58,8 +67,9 @@ public class Native117AsyncWorldgenSourceTest { String nativeSource = Files.readString(adapter, StandardCharsets.UTF_8); assertTrue(nativeSource.matches("(?s).*getChunkFuture\\(.*ChunkStatus\\.FULL,\\s*true\\).*")); assertTrue(nativeSource.contains("isLightCorrect()")); - assertTrue(nativeSource.contains("addRegionTicket(TICKET, position, 0, position)")); - assertTrue(nativeSource.contains("removeRegionTicket(TICKET, position, 0, position)")); + assertTrue(nativeSource.contains("new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING)")); + assertTrue(nativeSource.contains("addTicketWithRadius(TICKET, position, 0)")); + assertTrue(nativeSource.contains("removeTicketWithRadius(TICKET, position, 0)")); assertFalse(nativeSource.contains(".join()")); assertFalse(nativeSource.contains("world.getChunk(")); } diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117EntityContextTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117EntityContextTest.java index 5fa6e96a..ecb58fc5 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117EntityContextTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117EntityContextTest.java @@ -5,11 +5,8 @@ import com.google.gson.JsonObject; import java.lang.reflect.Field; import java.util.UUID; -import java.util.function.Supplier; import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.vehicle.Boat; -import net.minecraft.util.profiling.InactiveProfiler; -import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraft.world.entity.vehicle.boat.Boat; import net.minecraft.resources.ResourceKey; import net.minecraft.server.Bootstrap; import net.minecraft.world.level.Level; @@ -22,7 +19,7 @@ public class Native117EntityContextTest { @Test public void observesActualRegistryKeyAndWorldIdentity() throws Exception { ServerLevel world = world(Level.NETHER); - Boat boat = new Boat(EntityType.BOAT, world); + Boat boat = boat(world); JsonObject actual = Native117EntityContext.observe(boat, world); assertEquals("minecraft:the_nether", actual.get("worldDimensionKey").getAsString()); assertTrue(actual.get("sameWorld").getAsBoolean()); @@ -31,14 +28,14 @@ public class Native117EntityContextTest { @Test public void observesEndWithoutAnyExpectedFixtureDimension() throws Exception { ServerLevel world = world(Level.END); - Boat boat = new Boat(EntityType.BOAT, world); + Boat boat = boat(world); assertEquals("minecraft:the_end", Native117EntityContext.observe(boat, world).get("worldDimensionKey").getAsString()); } @Test public void recordsActualPassengerWorldAndUuid() throws Exception { ServerLevel world = world(Level.NETHER); - Boat vehicle = new Boat(EntityType.BOAT, world), passenger = new Boat(EntityType.BOAT, world); - assertTrue(passenger.startRiding(vehicle, true)); + Boat vehicle = boat(world), passenger = boat(world); + assertTrue(passenger.startRiding(vehicle, true, false)); JsonObject actual = Native117EntityContext.observe(vehicle, world).getAsJsonObject("entityWorlds"); assertEquals(2, actual.entrySet().size()); assertEquals("minecraft:the_nether", actual.get(passenger.getUUID().toString()).getAsString()); @@ -46,13 +43,13 @@ public class Native117EntityContextTest { @Test public void rejectsDifferentWorldInstanceEvenWithSameRegistryKey() throws Exception { ServerLevel actual = world(Level.NETHER), other = world(Level.NETHER); - reject(new Boat(EntityType.BOAT, actual), other); + reject(boat(actual), other); } @Test public void rejectsPassengerWithDifferentActualWorld() throws Exception { ServerLevel world = world(Level.NETHER); - Boat vehicle = new Boat(EntityType.BOAT, world), passenger = new Boat(EntityType.BOAT, world); - assertTrue(passenger.startRiding(vehicle, true)); + Boat vehicle = boat(world), passenger = boat(world); + assertTrue(passenger.startRiding(vehicle, true, false)); Field level = net.minecraft.world.entity.Entity.class.getDeclaredField("level"); level.setAccessible(true); level.set(passenger, world(Level.END)); @@ -61,15 +58,15 @@ public class Native117EntityContextTest { @Test public void rejectsDuplicatePassengerUuid() throws Exception { ServerLevel world = world(Level.NETHER); - Boat vehicle = new Boat(EntityType.BOAT, world), passenger = new Boat(EntityType.BOAT, world); + Boat vehicle = boat(world), passenger = boat(world); passenger.setUUID(vehicle.getUUID()); - assertTrue(passenger.startRiding(vehicle, true)); + assertTrue(passenger.startRiding(vehicle, true, false)); reject(vehicle, world); } @Test public void observationDoesNotChangeEntityIdentityOrPassengers() throws Exception { ServerLevel world = world(Level.NETHER); - Boat boat = new Boat(EntityType.BOAT, world); + Boat boat = boat(world); UUID before = boat.getUUID(); Native117EntityContext.observe(boat, world); assertEquals(before, boat.getUUID()); @@ -93,18 +90,21 @@ static ServerLevel world(ResourceKey key) throws Exception { .invoke(field.get(null), EntityFixtureWorld.class); set(world, "dimension", key); set(world, "random", net.minecraft.util.RandomSource.create(0)); - set(world, "profiler", (Supplier) () -> InactiveProfiler.INSTANCE); Field ticks = ServerLevel.class.getDeclaredField("entityTickList"); ticks.setAccessible(true); ticks.set(world, new net.minecraft.world.level.entity.EntityTickList()); return world; } + static Boat boat(ServerLevel world) { + return new Boat(EntityType.OAK_BOAT, world, () -> net.minecraft.world.item.Items.OAK_BOAT); + } + private static void set(ServerLevel world, String name, Object value) throws Exception { Field field = Level.class.getDeclaredField(name); field.setAccessible(true); field.set(world, value); } private static final class EntityFixtureWorld extends ServerLevel { - private EntityFixtureWorld() { super(null, null, null, null, null, null, null, false, 0L, java.util.List.of(), false, null); } + private EntityFixtureWorld() { super(null, null, null, null, null, null, false, 0L, java.util.List.of(), false, null); } @Override public net.minecraft.world.flag.FeatureFlagSet enabledFeatures() { return net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS; diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117HammerQueryGateTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117HammerQueryGateTest.java index eff7763b..0a879a07 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117HammerQueryGateTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117HammerQueryGateTest.java @@ -91,6 +91,7 @@ private static JsonObject visible() { assertTrue(body.indexOf("ADVHAMMER BEFORE") < body.indexOf("HammerQueryGate.ready(")); assertTrue(body.indexOf("HammerQueryGate.ready(") < body.indexOf(".useOn(")); assertTrue(body.indexOf(".useOn(") < body.indexOf("ADVHAMMER AFTER")); + assertTrue(body.contains("attempt.addProperty(\"action\", action == InteractionResult.CONSUME ? \"CONSUME\" : action.toString())")); assertTrue(body.contains("action == InteractionResult.CONSUME && source.getItem().isEmpty()")); assertTrue(body.contains("player.getMainHandItem().getDamageValue() == 1, \"Live hammer failed \"")); assertTrue(body.contains("id(entity.getItem()).equals(id(expected))")); diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117JoiningHoldTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117JoiningHoldTest.java index 516e565f..e4880215 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117JoiningHoldTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117JoiningHoldTest.java @@ -6,8 +6,7 @@ import java.util.UUID; import net.minecraft.server.Bootstrap; import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.vehicle.Boat; +import net.minecraft.world.entity.vehicle.boat.Boat; import net.minecraft.world.level.Level; import net.minecraftforge.event.entity.EntityJoinLevelEvent; import org.junit.BeforeClass; @@ -20,7 +19,7 @@ public class Native117JoiningHoldTest { @Test public void joiningActorIsHeldBeforeItCanEnterTheNativeTickList() throws Exception { ServerLevel world = Native117EntityContextTest.world(Level.OVERWORLD); - Boat boat = new Boat(EntityType.BOAT, world); + Boat boat = Native117EntityContextTest.boat(world); boat.tickCount = 17; UUID identity = boat.getUUID(); MachineOperationalProbe probe = new MachineOperationalProbe(); @@ -42,7 +41,7 @@ public class Native117JoiningHoldTest { @Test public void nativeJoinWhileMeasuringIsNotFrozen() throws Exception { ServerLevel world = Native117EntityContextTest.world(Level.OVERWORLD); - Boat boat = new Boat(EntityType.BOAT, world); + Boat boat = Native117EntityContextTest.boat(world); MachineOperationalProbe probe = new MachineOperationalProbe(); Field paused = MachineOperationalProbe.class.getDeclaredField("paused"); paused.setAccessible(true); paused.setBoolean(probe, false); @@ -53,7 +52,7 @@ public class Native117JoiningHoldTest { } @Test public void previouslyDisabledActorRemainsDisabledAfterHold() throws Exception { - Boat boat = new Boat(EntityType.BOAT, Native117EntityContextTest.world(Level.OVERWORLD)); + Boat boat = Native117EntityContextTest.boat(Native117EntityContextTest.world(Level.OVERWORLD)); boat.canUpdate(false); NativeEntityHold hold = new NativeEntityHold(); hold.hold(boat); hold.hold(boat); hold.restore(); @@ -61,7 +60,7 @@ public class Native117JoiningHoldTest { } @Test public void nativeTickDriftCannotBeHiddenByRestoringTheCounter() throws Exception { - Boat boat = new Boat(EntityType.BOAT, Native117EntityContextTest.world(Level.OVERWORLD)); + Boat boat = Native117EntityContextTest.boat(Native117EntityContextTest.world(Level.OVERWORLD)); NativeEntityHold hold = new NativeEntityHold(); hold.hold(boat); boat.tickCount++; diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117PlayerIoTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117PlayerIoTest.java index 3468d514..28a1a822 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117PlayerIoTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117PlayerIoTest.java @@ -10,6 +10,7 @@ import java.util.UUID; import java.util.List; import net.minecraft.core.RegistryAccess; +import net.minecraft.core.UUIDUtil; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.NbtAccounter; import net.minecraft.nbt.NbtOps; @@ -17,6 +18,8 @@ import com.mojang.serialization.Dynamic; import net.minecraft.util.datafix.fixes.References; import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.EntityEquipment; +import net.minecraft.world.ItemStackWithSlot; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.ItemStack; import net.minecraft.nbt.CompoundTag; @@ -29,6 +32,9 @@ import net.minecraft.util.datafix.DataFixers; import net.minecraft.server.Bootstrap; import net.minecraft.world.level.storage.PlayerDataStorage; +import net.minecraft.world.level.storage.TagValueOutput; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; @@ -54,7 +60,7 @@ public class Native117PlayerIoTest { net.minecraft.core.Registry.register(enchantments, holder.key(), holder.value())); // Only the item-codec fixture definitions are synthetic; live tests load Steam's data pack. for (String name : List.of("high_explosive", "powderless", "rapid_reload", "recoil")) { - var id = net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("basesteam", name); + var id = net.minecraft.resources.Identifier.fromNamespaceAndPath("basesteam", name); var enchantment = Enchantment.enchantment(Enchantment.definition( net.minecraft.core.HolderSet.direct(net.minecraft.world.item.Items.BOW.builtInRegistryHolder()), 1, 1, Enchantment.constantCost(1), Enchantment.constantCost(2), 1, @@ -105,8 +111,9 @@ public class Native117PlayerIoTest { Native117PlayerIo.save(players(data(directory), false), first); HeadlessPlayer second = player(9, 8); PlayerDataStorage reopened = data(directory); - CompoundTag saved = reopened.load(second).orElseThrow(); + CompoundTag saved = reopened.load(second.nameAndId()).orElseThrow(); assertNotNull(saved); + NativeActorIo.load(second, saved); assertSaved(saved, second, 1, 0); Native117PlayerIo.save(players(reopened, false), second); assertEquals(saved, readCompressed(new File(directory, ID + ".dat"))); @@ -138,31 +145,43 @@ public class Native117PlayerIoTest { } } + @Test public void nativeStorageIoFailureCannotReportASuccessfulSave() throws Exception { + File blocked = temporary.newFile("not-a-directory"); + try { + Native117PlayerIo.save(players(data(blocked), false), player(1, 0)); + fail("Native storage I/O failure was hidden"); + } catch (IllegalStateException expected) { + assertEquals("Native player save failed", expected.getMessage()); + } + } + private static void assertSaved(CompoundTag saved, HeadlessPlayer player, int count, int slot) throws Exception { - assertEquals(SharedConstants.getCurrentVersion().getDataVersion().getVersion(), saved.getInt("DataVersion")); - assertEquals(ID, saved.getUUID("UUID")); + assertEquals(SharedConstants.getCurrentVersion().dataVersion().version(), (int) saved.getInt("DataVersion").orElseThrow()); + assertEquals(ID, saved.read("UUID", UUIDUtil.CODEC).orElseThrow()); assertFalse(saved.contains("UUIDMost")); assertFalse(saved.contains("UUIDLeast")); - ListTag inventory = saved.getList("Inventory", 10); + ListTag inventory = saved.getList("Inventory").orElseThrow(); assertEquals(1, inventory.size()); - CompoundTag stack = inventory.getCompound(0); - assertEquals("minecraft:enchanted_book", stack.getString("id")); - assertEquals(count, stack.getInt("count")); - assertEquals(slot, stack.getByte("Slot")); + CompoundTag stack = inventory.getCompound(0).orElseThrow(); + assertEquals("minecraft:enchanted_book", stack.getString("id").orElseThrow()); + assertEquals(count, (int) stack.getInt("count").orElseThrow()); + assertEquals(slot, (int) stack.getByte("Slot").orElseThrow()); assertFalse(stack.contains("Damage")); - ItemStack decoded = ItemStack.parse(registries, stack).orElseThrow(); + ItemStack decoded = ItemStack.CODEC.parse(registries.createSerializationContext(NbtOps.INSTANCE), stack).getOrThrow(); var stored = decoded.get(net.minecraft.core.component.DataComponents.STORED_ENCHANTMENTS); assertNotNull(stack.toString(), stored); var levels = new java.util.HashMap(); stored.entrySet().forEach(entry -> levels.put( - entry.getKey().unwrapKey().orElseThrow().location().toString(), entry.getIntValue())); + entry.getKey().unwrapKey().orElseThrow().identifier().toString(), entry.getIntValue())); assertEquals(stack.toString(), 5, levels.size()); assertEquals(Integer.valueOf(3), levels.get("minecraft:efficiency")); for (String id : List.of("high_explosive", "powderless", "rapid_reload", "recoil")) { assertEquals(Integer.valueOf(1), levels.get("basesteam:" + id)); } assertTrue(ItemStack.matches(decoded, player.items.getItem(slot))); - assertEquals(inventory, player.items.save(new ListTag())); + TagValueOutput output = TagValueOutput.createWithContext(NativeActorIo.reporter("inventory test"), registries); + player.items.save(output.list("Inventory", ItemStackWithSlot.CODEC)); + assertEquals(inventory, output.buildResult().getList("Inventory").orElseThrow()); } private static CompoundTag legacy(int version) throws Exception { @@ -180,7 +199,7 @@ private static CompoundTag legacy(int version) throws Exception { } private static CompoundTag book(int count) throws Exception { - CompoundTag stack = TagParser.parseTag("{id:\"minecraft:enchanted_book\",Count:1b,tag:{StoredEnchantments:[" + CompoundTag stack = TagParser.parseCompoundFully("{id:\"minecraft:enchanted_book\",Count:1b,tag:{StoredEnchantments:[" + "{id:\"minecraft:efficiency\",lvl:3s},{id:\"basesteam:high_explosive\",lvl:1s}," + "{id:\"basesteam:powderless\",lvl:1s},{id:\"basesteam:rapid_reload\",lvl:1s}," + "{id:\"basesteam:recoil\",lvl:1s}]}}"); @@ -191,11 +210,12 @@ private static CompoundTag book(int count) throws Exception { private static HeadlessPlayer player(int count, int slot) throws Exception { HeadlessPlayer player = allocate(HeadlessPlayer.class); player.setUUID(ID); - player.items = new Inventory(player); + set(net.minecraft.world.entity.player.Player.class, player, "gameProfile", new GameProfile(ID, "Headless")); + player.items = new Inventory(player, new EntityEquipment()); CompoundTag nativeBook = (CompoundTag) DataFixers.getDataFixer().update(References.ITEM_STACK, new Dynamic<>(NbtOps.INSTANCE, book(count)), 3465, - SharedConstants.getCurrentVersion().getDataVersion().getVersion()).getValue(); - player.items.setItem(slot, ItemStack.parse(registries, nativeBook).orElseThrow()); + SharedConstants.getCurrentVersion().dataVersion().version()).getValue(); + player.items.setItem(slot, ItemStack.CODEC.parse(registries.createSerializationContext(NbtOps.INSTANCE), nativeBook).getOrThrow()); return player; } @@ -235,20 +255,19 @@ private static final class HeadlessPlayer extends ServerPlayer { Inventory items; private HeadlessPlayer() { super(null, null, new GameProfile(ID, "Headless"), net.minecraft.server.level.ClientInformation.createDefault()); } @Override public RegistryAccess registryAccess() { return registries; } - @Override public CompoundTag saveWithoutId(CompoundTag root) { - root.putInt("DataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); - root.putUUID("UUID", getUUID()); - root.put("Inventory", items.save(new ListTag())); - return root; + @Override public void saveWithoutId(ValueOutput root) { + root.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); + root.store("UUID", UUIDUtil.CODEC, getUUID()); + items.save(root.list("Inventory", ItemStackWithSlot.CODEC)); } - @Override public void load(CompoundTag root) { - setUUID(root.getUUID("UUID")); - items.load(root.getList("Inventory", 10)); + @Override public void load(ValueInput root) { + setUUID(root.read("UUID", UUIDUtil.CODEC).orElseThrow()); + items.load(root.list("Inventory", ItemStackWithSlot.CODEC).orElseThrow()); } } private static class HeadlessPlayerList extends PlayerList { - private HeadlessPlayerList() { super(null, null, null, 0); } + private HeadlessPlayerList() { super(null, null, null, null); } } private static final class CountingPlayerList extends HeadlessPlayerList { diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117RecipeTransportTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117RecipeTransportTest.java index 85704ccc..1013ac14 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117RecipeTransportTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117RecipeTransportTest.java @@ -11,6 +11,11 @@ public class Native117RecipeTransportTest { @BeforeClass public static void bootstrap() { net.minecraft.SharedConstants.tryDetectVersion(); net.minecraft.server.Bootstrap.bootStrap(); } + @Test public void nativeRecipeKeyRetainsTheExternalIdentifier() { + var key = RecipeUnlockProbe.recipeKey("basepower:starch_from_potato"); + assertTrue(key.isFor(net.minecraft.core.registries.Registries.RECIPE)); + assertEquals("basepower:starch_from_potato", key.identifier().toString()); + } @Test public void nativeChannelActivationSuppliesRealConnectionAttributesWithoutASocket() { Connection connection = RecipeUnlockProbe.nativeTransport(); EmbeddedChannel channel = (EmbeddedChannel) connection.channel(); diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshotTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshotTest.java index 6abc8d24..228cb4f8 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshotTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshotTest.java @@ -12,6 +12,7 @@ import java.util.HashSet; import java.util.List; import net.minecraft.SharedConstants; +import net.minecraft.core.UUIDUtil; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtIo; import net.minecraft.server.Bootstrap; @@ -19,9 +20,10 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.EntitySpawnReason; import net.minecraft.world.entity.LightningBolt; import net.minecraft.world.entity.item.ItemEntity; -import net.minecraft.world.entity.vehicle.Boat; +import net.minecraft.world.entity.vehicle.boat.Boat; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.level.Level; @@ -60,8 +62,8 @@ public class Native117StoppedEntitiesSnapshotTest { JsonObject row = Native117StoppedEntitiesSnapshot.entities(world, List.of(entity), new HashSet<>()).get(0).getAsJsonObject(); CompoundTag captured = decode(row); assertEquals(before, captured); - assertEquals(7, captured.getCompound("Item").getInt("count")); - assertEquals("keep exactly", captured.getCompound("ForgeData").getString("Private")); + assertEquals(7, (int) captured.getCompound("Item").orElseThrow().getInt("count").orElseThrow()); + assertEquals("keep exactly", captured.getCompound("ForgeData").orElseThrow().getString("Private").orElseThrow()); assertEquals("minecraft:the_nether", row.get("worldDimensionKey").getAsString()); assertEquals(entity.getUUID().toString(), row.get("uuid").getAsString()); assertEquals(ticks, entity.tickCount); @@ -71,17 +73,17 @@ public class Native117StoppedEntitiesSnapshotTest { @Test public void capturesRootAndPassengerNativeNbtWithoutDismounting() throws Exception { ServerLevel world = Native117EntityContextTest.world(Level.END); - Boat root = new Boat(EntityType.BOAT, world), passenger = new Boat(EntityType.BOAT, world); + Boat root = Native117EntityContextTest.boat(world), passenger = Native117EntityContextTest.boat(world); root.setPos(8.5, 80, 8.5); passenger.setPos(8.5, 81, 8.5); - assertTrue(passenger.startRiding(root, true)); + assertTrue(passenger.startRiding(root, true, false)); CompoundTag before = Native117StoppedEntitiesSnapshot.serialized(root).copy(); JsonArray rows = Native117StoppedEntitiesSnapshot.entities(world, List.of(root, passenger), new HashSet<>()); assertEquals(2, rows.size()); assertEquals(before, decode(rows.get(0).getAsJsonObject())); - assertEquals(passenger.getUUID(), decode(rows.get(0).getAsJsonObject()).getList("Passengers", 10).getCompound(0).getUUID("UUID")); - assertEquals(passenger.getUUID(), decode(rows.get(1).getAsJsonObject()).getUUID("UUID")); - assertEquals(decode(rows.get(0).getAsJsonObject()).getList("Passengers", 10).getCompound(0), + assertEquals(passenger.getUUID(), decode(rows.get(0).getAsJsonObject()).getList("Passengers").orElseThrow().getCompound(0).orElseThrow().read("UUID", UUIDUtil.CODEC).orElseThrow()); + assertEquals(passenger.getUUID(), decode(rows.get(1).getAsJsonObject()).read("UUID", UUIDUtil.CODEC).orElseThrow()); + assertEquals(decode(rows.get(0).getAsJsonObject()).getList("Passengers").orElseThrow().getCompound(0).orElseThrow(), decode(rows.get(1).getAsJsonObject())); assertEquals(root.getUUID().toString(), rows.get(1).getAsJsonObject().get("vehicleUuid").getAsString()); assertSame(root, passenger.getVehicle()); @@ -93,7 +95,7 @@ public class Native117StoppedEntitiesSnapshotTest { ItemEntity entity = new ItemEntity(world, -0.25, 80, 8.5, new ItemStack(Items.DIRT, 7)); entity.getPersistentData().putInt("Private", 42); CompoundTag saved = Native117StoppedEntitiesSnapshot.serialized(entity); - Entity loaded = EntityType.loadEntityRecursive(saved.copy(), world, value -> value); + Entity loaded = EntityType.loadEntityRecursive(saved.copy(), world, EntitySpawnReason.LOAD, value -> value); assertNotNull(loaded); assertEquals(saved, Native117StoppedEntitiesSnapshot.serialized(loaded)); } @@ -108,7 +110,7 @@ public class Native117StoppedEntitiesSnapshotTest { @Test public void rejectsWrongWorldAndDuplicateEnumeratedUuid() throws Exception { ServerLevel world = Native117EntityContextTest.world(Level.OVERWORLD); - Boat root = new Boat(EntityType.BOAT, world), other = new Boat(EntityType.BOAT, world); + Boat root = Native117EntityContextTest.boat(world), other = Native117EntityContextTest.boat(world); other.setUUID(root.getUUID()); try { Native117StoppedEntitiesSnapshot.entities(world, List.of(root, other), new HashSet<>()); @@ -126,7 +128,7 @@ public class Native117StoppedEntitiesSnapshotTest { try { Native117StoppedEntitiesSnapshot.entities(world, List.of(transientEntity), new HashSet<>()); fail("Native writer refusal was hidden"); - } catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("writer refused")); } + } catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("save refused")); } } @Test public void replacesOnlyRuntimeSnapshotAndLeavesNoTemporaryFile() throws Exception { diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshotV2Test.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshotV2Test.java index 9d87bd92..80b1c5f9 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshotV2Test.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native117StoppedEntitiesSnapshotV2Test.java @@ -26,20 +26,21 @@ public class Native117StoppedEntitiesSnapshotV2Test { ServerLevel world = Native117EntityContextTest.world(Level.NETHER); LeashFenceKnotEntity knot = new LeashFenceKnotEntity(world, new BlockPos(154, 100, 8)); ItemEntity item = new ItemEntity(world, 154, 100, 8, new ItemStack(Items.DIRT, 7)); - CompoundTag before = knot.saveWithoutId(new CompoundTag()).copy(); + CompoundTag before = NativeActorIo.saveWithoutId(knot).copy(); assertFalse(before.contains("TileX")); assertFalse(before.contains("TileY")); assertFalse(before.contains("TileZ")); - assertEquals(154.5, before.getList("Pos", 6).getDouble(0), 0.0); - assertEquals(100.375, before.getList("Pos", 6).getDouble(1), 0.0); - assertEquals(8.5, before.getList("Pos", 6).getDouble(2), 0.0); + assertEquals(154.5, before.getList("Pos").orElseThrow().getDouble(0).orElseThrow(), 0.0); + assertEquals(100.375, before.getList("Pos").orElseThrow().getDouble(1).orElseThrow(), 0.0); + assertEquals(8.5, before.getList("Pos").orElseThrow().getDouble(2).orElseThrow(), 0.0); + assertFalse("The native knot overrides the block-attached writer with an empty writer", before.contains("block_pos")); CompoundTag persistent = Native117StoppedEntitiesSnapshot.serialized(item).copy(); JsonObject record = Native117StoppedEntitiesSnapshotV2.partition(world, List.of(knot, item), new HashSet<>()); assertEquals(1, record.get("entityCount").getAsInt()); assertEquals(1, record.get("nonpersistentHelperCount").getAsInt()); assertEquals(2, record.get("observedEntityCount").getAsInt()); assertEquals("minecraft:leash_knot", record.getAsJsonArray("nonpersistentHelpers").get(0).getAsJsonObject().get("id").getAsString()); - assertEquals(before, knot.saveWithoutId(new CompoundTag())); + assertEquals(before, NativeActorIo.saveWithoutId(knot)); assertEquals(persistent, Native117StoppedEntitiesSnapshot.serialized(item)); assertEquals(0, knot.tickCount); assertFalse(knot.isRemoved()); @@ -74,8 +75,18 @@ public class Native117StoppedEntitiesSnapshotV2Test { } catch (IllegalStateException expected) { assertTrue(expected.toString().contains("another world")); } } - @Test public void resourceIsExactIndependentNativeControl() throws Exception { - assertEquals("83990C70FA3F6AD0CDC2803CA956B6CF1227EA5B5701D95175874B7D9EEA0AB8", - Native117StoppedEntitiesSnapshotV2.sha(Native117StoppedEntitiesSnapshotV2.contract())); + @Test public void historicalControlIsUnchangedAndCannotQualifyThisEngine() throws Exception { + try (var input = getClass().getResourceAsStream("/assets/advantageworkstest/native1211-helper-contract.json")) { + assertNotNull(input); + byte[] historical = input.readAllBytes(); + assertEquals("83990C70FA3F6AD0CDC2803CA956B6CF1227EA5B5701D95175874B7D9EEA0AB8", + Native117StoppedEntitiesSnapshotV2.sha(historical)); + try { + Native117StoppedEntitiesSnapshotV2.validateContract(historical); + fail("Historical control accepted for the target engine"); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().contains("pinned engine")); + } + } } } diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native118EngineSignaturesTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native118EngineSignaturesTest.java index 4b9f094e..0aeb9921 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native118EngineSignaturesTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native118EngineSignaturesTest.java @@ -20,17 +20,19 @@ public class Native118EngineSignaturesTest { assertEquals(LevelChunk.class, ServerChunkCache.class.getMethod("getChunkNow", int.class, int.class).getReturnType()); assertEquals(ServerLevel.class, MinecraftServer.class.getMethod("getLevel", ResourceKey.class).getReturnType()); Class registryProvider = net.minecraft.core.HolderLookup.Provider.class; - assertEquals(void.class, BlockEntity.class.getMethod("loadWithComponents", CompoundTag.class, registryProvider).getReturnType()); + assertEquals(void.class, BlockEntity.class.getMethod("loadWithComponents", + net.minecraft.world.level.storage.ValueInput.class).getReturnType()); assertEquals(CompoundTag.class, BlockEntity.class.getMethod("saveWithFullMetadata", registryProvider).getReturnType()); - assertTrue(Modifier.isProtected(BlockEntity.class.getDeclaredMethod("saveAdditional", CompoundTag.class, registryProvider).getModifiers())); + assertTrue(Modifier.isProtected(BlockEntity.class.getDeclaredMethod("saveAdditional", + net.minecraft.world.level.storage.ValueOutput.class).getModifiers())); } - @Test public void nativePlayerSaveRemainsProtectedAndRespawnUsesRegistryKeys() throws Exception { + @Test public void nativePlayerSaveRemainsProtectedAndRespawnUsesNativeConfiguration() throws Exception { Method save = PlayerList.class.getDeclaredMethod("save", ServerPlayer.class); assertTrue(Modifier.isProtected(save.getModifiers())); - assertEquals(ResourceKey.class, ServerPlayer.class.getMethod("getRespawnDimension").getReturnType()); - assertEquals(void.class, ServerPlayer.class.getMethod("setRespawnPosition", ResourceKey.class, - net.minecraft.core.BlockPos.class, float.class, boolean.class, boolean.class).getReturnType()); + assertEquals(ServerPlayer.RespawnConfig.class, ServerPlayer.class.getMethod("getRespawnConfig").getReturnType()); + assertEquals(void.class, ServerPlayer.class.getMethod("setRespawnPosition", + ServerPlayer.RespawnConfig.class, boolean.class).getReturnType()); } @Test public void realNativeBitStoragePadsFiveSevenAndThirteenBitValues() throws Exception { diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native1201PackMetadataTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native1201PackMetadataTest.java index 1e2570a0..a501a88c 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native1201PackMetadataTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native1201PackMetadataTest.java @@ -14,10 +14,17 @@ public class Native1201PackMetadataTest { assertNotNull(input); JsonObject pack = new JsonParser().parse(new InputStreamReader(input, StandardCharsets.UTF_8)) .getAsJsonObject().getAsJsonObject("pack"); - assertTrue(pack.getAsJsonPrimitive("pack_format").isNumber()); - assertEquals(32, pack.get("pack_format").getAsInt()); - assertEquals(32, pack.get("forge:resource_pack_format").getAsInt()); - assertEquals(41, pack.get("forge:data_pack_format").getAsInt()); + assertFalse(pack.has("pack_format")); + assertFalse(pack.has("forge:resource_pack_format")); + assertFalse(pack.has("forge:data_pack_format")); + var section = net.minecraft.server.packs.metadata.pack.PackMetadataSection.forPackType( + net.minecraft.server.packs.PackType.SERVER_DATA).codec() + .parse(com.mojang.serialization.JsonOps.INSTANCE, pack).getOrThrow(); + var formats = section.supportedFormats(); + assertTrue(formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(94, 1))); + assertFalse(formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(75, 0))); + assertFalse(formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(94, 0))); + assertFalse(formats.isValueInRange(net.minecraft.server.packs.metadata.pack.PackFormat.of(95))); } } } diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native1201TestFlatTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native1201TestFlatTest.java index c55228a2..3a609580 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native1201TestFlatTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native1201TestFlatTest.java @@ -32,7 +32,11 @@ public class Native1201TestFlatTest { var base = RegistryLayer.createRegistryAccess(); try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, List.of(ServerPacksSource.createVanillaPackSource()))) { - var worldgen = RegistryDataLoader.load(resources, base.compositeAccess(), + var pendingTags = net.minecraft.tags.TagLoader.loadTagsForExistingRegistries( + resources, base.getLayer(RegistryLayer.STATIC)); + var lookups = net.minecraft.tags.TagLoader.buildUpdatedLookups( + base.getAccessForLoading(RegistryLayer.WORLDGEN), pendingTags); + var worldgen = RegistryDataLoader.load(resources, lookups, RegistryDataLoader.WORLDGEN_REGISTRIES); registries = base.replaceFrom(RegistryLayer.WORLDGEN, worldgen).compositeAccess(); } @@ -44,12 +48,13 @@ public class Native1201TestFlatTest { assertNotNull("Missing shared flat settings", input); json = JsonParser.parseReader(new InputStreamReader(input, StandardCharsets.UTF_8)).getAsJsonObject(); } - Class type = Class.forName("net.minecraft.server.dedicated.DedicatedServerProperties$WorldDimensionData"); - var constructor = type.getDeclaredConstructor(JsonObject.class, String.class); - constructor.setAccessible(true); - var create = type.getDeclaredMethod("create", RegistryAccess.class); - create.setAccessible(true); - var dimensions = (WorldDimensions) create.invoke(constructor.newInstance(json, "minecraft:flat"), registries); + var properties = new java.util.Properties(); + properties.setProperty("generator-settings", json.toString()); + properties.setProperty("level-type", "minecraft:flat"); + properties.setProperty("level-seed", "42"); + properties.setProperty("generate-structures", "false"); + var settings = new net.minecraft.server.dedicated.DedicatedServerProperties(properties); + var dimensions = settings.createDimensions(registries); var generator = (FlatLevelSource) dimensions.overworld(); var flat = generator.settings(); assertEquals(3, flat.getLayersInfo().size()); @@ -58,7 +63,7 @@ public class Native1201TestFlatTest { assertEquals(0, flat.structureOverrides().orElseThrow().size()); JsonObject saved = WorldGenSettings.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, registries), - new WorldGenSettings(new WorldOptions(42L, false, false), dimensions)) + new WorldGenSettings(settings.worldOptions, dimensions)) .getOrThrow(AssertionError::new).getAsJsonObject(); assertEquals(42L, saved.get("seed").getAsLong()); assertFalse(saved.get("generate_features").getAsBoolean()); diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111DefinitionTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111DefinitionTest.java new file mode 100644 index 00000000..5e7cb9a0 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111DefinitionTest.java @@ -0,0 +1,17 @@ +package zone.moddev.mc.advantageworks; + +import com.google.gson.Gson; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class Native12111DefinitionTest { + @Test public void retainsEveryExistingStationWithoutEditingItsExpectedBehaviour() { + Gson gson = new Gson(); + WorksDefinition source = WorksDefinitionLoader.loadNative1211(); + WorksDefinition target = WorksDefinitionLoader.loadNative12111(); + assertEquals("1.21.11", target.minecraft); + assertEquals(21, target.stations.size()); + assertEquals(gson.toJsonTree(source.stations), gson.toJsonTree(target.stations)); + assertEquals(gson.toJsonTree(source.districts), gson.toJsonTree(target.districts)); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111HelperContractTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111HelperContractTest.java new file mode 100644 index 00000000..6fe6c3ba --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111HelperContractTest.java @@ -0,0 +1,99 @@ +package zone.moddev.mc.advantageworks; + +import static org.junit.Assert.*; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.InputStream; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipFile; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.datafix.fixes.BlockStateData; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.decoration.LeashFenceKnotEntity; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.TagValueOutput; +import org.junit.BeforeClass; +import org.junit.Test; + +public class Native12111HelperContractTest { + private static final String SHA = "EBFF575F4777A43C12255250B704C06FF700D5160AFCAFE7252508353CD430C4"; + + @BeforeClass public static void bootstrap() { SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); } + + @Test public void targetResourceIsTheExactIndependentNativeControl() throws Exception { + byte[] bytes = Native117StoppedEntitiesSnapshotV2.contract(); + assertEquals(SHA, Native117StoppedEntitiesSnapshotV2.sha(bytes)); + JsonObject control = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals("native12111-leash-knot-nosave-v1", control.get("policy").getAsString()); + assertEquals(SharedConstants.getCurrentVersion().name(), control.get("minecraftVersion").getAsString()); + assertEquals(SharedConstants.getCurrentVersion().dataVersion().version(), control.get("dataVersion").getAsInt()); + assertEquals("61.1.0", control.get("forgeVersion").getAsString()); + for (String key : List.of("noPower", "powerAccessTransformerAbsent", "noHarness", "noServer", "noTicks", "nativeMethodsUnmodified", + "observationalUuidPreserved", "observationalStateUnchanged", "refusedWritersLeaveNoPayload")) { + assertTrue(key, control.get(key).getAsBoolean()); + } + assertFalse(control.get("canSerialize").getAsBoolean()); + assertFalse(control.get("rootWriterAccepted").getAsBoolean()); + assertFalse(control.get("passengerWriterAccepted").getAsBoolean()); + assertFalse(control.get("encodedIdPresent").getAsBoolean()); + assertTrue(control.get("storageSelectorAccepts").getAsBoolean()); + } + + @Test public void evidenceHashesBindToTheActualPinnedClassesAndPrimarySources() throws Exception { + JsonObject control = JsonParser.parseString(new String(Native117StoppedEntitiesSnapshotV2.contract(), StandardCharsets.UTF_8)).getAsJsonObject(); + Path engine = Path.of(Entity.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + assertEquals("Loaded engine: " + engine, control.get("engineJarSha256").getAsString(), sha(Files.readAllBytes(engine))); + String name = engine.getFileName().toString(); + Path sources = engine.resolveSibling(name.substring(0, name.length() - 4) + "-sources.jar"); + assertEquals(control.get("engineSourcesSha256").getAsString(), sha(Files.readAllBytes(sources))); + try (ZipFile zip = new ZipFile(sources.toFile())) { + for (Class type : List.of(Entity.class, EntityType.class, LeashFenceKnotEntity.class, BlockStateData.class)) { + String path = type.getName().replace('.', '/'); + try (InputStream input = type.getResourceAsStream("/" + path + ".class")) { + assertNotNull(input); + assertEquals(control.getAsJsonObject("nativeClassSha256").get(type.getName()).getAsString(), sha(input.readAllBytes())); + } + try (InputStream input = zip.getInputStream(zip.getEntry(path + ".java"))) { + assertEquals(control.getAsJsonObject("nativeSourceSha256").get(path + ".java").getAsString(), sha(input.readAllBytes())); + } + } + } + for (var entry : control.getAsJsonObject("controlSourceSha256").entrySet()) { + assertEquals(entry.getKey(), entry.getValue().getAsString(), sha(Files.readAllBytes(Path.of("platform-controls", entry.getKey())))); + } + } + + @Test public void nativeArchiveHasNotInheritedThePowerAccessTransformer() throws Exception { + assertEquals(Modifier.PRIVATE | Modifier.STATIC | Modifier.FINAL, + BlockStateData.class.getDeclaredField("MAP").getModifiers()); + } + + @Test public void nativeSaveSelectionIsNotConfusedWithNativeWriterEligibility() throws Exception { + var world = Native117EntityContextTest.world(Level.NETHER); + var knot = new LeashFenceKnotEntity(world, new BlockPos(154, 100, 8)); + var before = NativeActorIo.saveWithoutId(knot).copy(); + assertTrue(knot.shouldBeSaved()); + assertFalse(EntityType.LEASH_KNOT.canSerialize()); + assertNull(knot.getEncodeId()); + TagValueOutput root = TagValueOutput.createWithContext(NativeActorIo.reporter("root control"), world.registryAccess()); + TagValueOutput passenger = TagValueOutput.createWithContext(NativeActorIo.reporter("passenger control"), world.registryAccess()); + assertFalse(knot.save(root)); + assertFalse(knot.saveAsPassenger(passenger)); + assertTrue(root.buildResult().isEmpty()); + assertTrue(passenger.buildResult().isEmpty()); + assertThrows(IllegalStateException.class, () -> NativeActorIo.save(knot)); + assertThrows(IllegalStateException.class, () -> NativeActorIo.saveAsPassenger(knot)); + assertEquals(before, NativeActorIo.saveWithoutId(knot)); + assertEquals(0, knot.tickCount); + } + + private static String sha(byte[] bytes) throws Exception { return Native117StoppedEntitiesSnapshotV2.sha(bytes); } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111OperationalFixturesTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111OperationalFixturesTest.java new file mode 100644 index 00000000..c3af6534 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111OperationalFixturesTest.java @@ -0,0 +1,160 @@ +package zone.moddev.mc.advantageworks; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.LockCode; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +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 Native12111OperationalFixturesTest { + @BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + } + + @Test public void nativeLockCodecInstallsAVanillaChestLockWithoutChangingItsStock() throws Exception { + ChestBlockEntity chest = chest(); + CompoundTag patch = PowerOperationalCases.nativeChestLock(chest.getLevel().registryAccess()); + assertTrue(patch.contains("lock")); + assertFalse(patch.contains("Lock")); + LockCode lock = LockCode.CODEC.parse(chest.getLevel().registryAccess() + .createSerializationContext(NbtOps.INSTANCE), patch.get("lock")).getOrThrow(); + assertTrue(lock.unlocksWith(new ItemStack(Items.STICK))); + assertFalse(lock.unlocksWith(ItemStack.EMPTY)); + assertFalse(lock.unlocksWith(new ItemStack(Items.DIRT))); + CompoundTag saved = WorksBlockData.save(chest); + saved.merge(patch); + WorksBlockData.load(chest, saved); + assertTrue(chest.isLocked()); + assertTrue(WorksBlockData.save(chest).contains("lock")); + assertTrue(chest.getItem(0).is(Items.DIRT)); + assertEquals(16, chest.getItem(0).getCount()); + saved.remove("lock"); + WorksBlockData.load(chest, saved); + assertFalse(chest.isLocked()); + assertEquals(16, chest.getItem(0).getCount()); + } + + @Test public void legacyStringLockDoesNotQualifyAsALockedNativeFixture() throws Exception { + ChestBlockEntity chest = chest(); + CompoundTag saved = WorksBlockData.save(chest); + saved.putString("Lock", "Works key"); + WorksBlockData.load(chest, saved); + assertFalse("The old fixture never installed a native lock", chest.isLocked()); + assertFalse(WorksBlockData.save(chest).contains("lock")); + } + + @Test public void completedOilChargeRequiresIndependentCounterAndSavedTotalEvidence() { + CompoundTag saved = completedBurn(); + assertTrue(SteamOperationalCases.oilChargeBurnedOut(0, 5, saved)); + assertFalse("An active burn cannot pass merely because its saved field is absent", + SteamOperationalCases.oilChargeBurnedOut(1, 5, saved)); + assertFalse("A never-ignited charge is not a completed burn", + SteamOperationalCases.oilChargeBurnedOut(0, 0, new CompoundTag())); + assertFalse(SteamOperationalCases.oilChargeBurnedOut(-1, 5, saved)); + assertFalse(SteamOperationalCases.oilChargeBurnedOut(0, 4, saved)); + } + + @Test public void missingWrongTypedAndContradictoryBurnEvidenceFailClosed() { + assertFalse(SteamOperationalCases.oilChargeBurnedOut(0, 5, new CompoundTag())); + CompoundTag saved = new CompoundTag(); + saved.putString("BurnTimeTotal", "5"); + assertFalse(SteamOperationalCases.oilChargeBurnedOut(0, 5, saved)); + saved = completedBurn(); + saved.putInt("BurnTime", 0); + assertFalse("The native serializer omits a zero counter", SteamOperationalCases.oilChargeBurnedOut(0, 5, saved)); + saved.putInt("BurnTime", 1); + assertFalse(SteamOperationalCases.oilChargeBurnedOut(0, 5, saved)); + } + + @Test public void unladenRejectionRequiresFailNoUseLifecycleAndAnUnmodifiedWeapon() { + ItemStack before = new ItemStack(Items.CROSSBOW), after = before.copy(); + assertTrue(SteamOperationalActors.rejectedUnladenUse(InteractionResult.FAIL, false, before, after)); + for (InteractionResult result : List.of(InteractionResult.PASS, InteractionResult.SUCCESS, InteractionResult.CONSUME)) + assertFalse(SteamOperationalActors.rejectedUnladenUse(result, false, before, after)); + assertFalse(SteamOperationalActors.rejectedUnladenUse(InteractionResult.FAIL, true, before, after)); + after.setDamageValue(1); + assertFalse(SteamOperationalActors.rejectedUnladenUse(InteractionResult.FAIL, false, before, after)); + after = before.copy(); + after.setCount(2); + assertFalse(SteamOperationalActors.rejectedUnladenUse(InteractionResult.FAIL, false, before, after)); + } + + @Test public void fabricatedEmptyChamberAndOtherComponentWritesAreNotRejectedUseWitnesses() { + ItemStack before = new ItemStack(Items.CROSSBOW), after = before.copy(); + WorksItemData.update(after, tag -> tag.putByte("loaded", (byte)0)); + assertFalse(SteamOperationalActors.rejectedUnladenUse(InteractionResult.FAIL, false, before, after)); + assertFalse(SteamOperationalActors.rejectedUnladenUse(InteractionResult.FAIL, false, after, after.copy())); + after = before.copy(); + WorksItemData.update(after, tag -> tag.putString("private", "changed")); + assertFalse(SteamOperationalActors.rejectedUnladenUse(InteractionResult.FAIL, false, before, after)); + } + + @Test public void fullInventoryOracleRejectsAmmunitionDamageComponentAndSlotChanges() { + SimpleContainer inventory = new SimpleContainer(new ItemStack(Items.CROSSBOW), new ItemStack(Items.ARROW, 3)); + List before = List.of(inventory.getItem(0).copy(), inventory.getItem(1).copy()); + assertTrue(SteamOperationalActors.sameInventory(inventory, before)); + inventory.getItem(1).shrink(1); + assertFalse(SteamOperationalActors.sameInventory(inventory, before)); + inventory.setItem(1, before.get(1).copy()); + inventory.getItem(0).setDamageValue(1); + assertFalse(SteamOperationalActors.sameInventory(inventory, before)); + inventory.setItem(0, before.get(0).copy()); + WorksItemData.update(inventory.getItem(1), tag -> tag.putInt("private", 1)); + assertFalse(SteamOperationalActors.sameInventory(inventory, before)); + assertFalse(SteamOperationalActors.sameInventory(new SimpleContainer(1), before)); + } + + @Test public void operatingFixturesUseNativePreconditionsCountersAndPublicRejectedUse() throws Exception { + String power = source("PowerOperationalCases.java"); + assertFalse(power.contains("putString(\"Lock\"")); + assertTrue(power.contains("LockCode.CODEC.encodeStart(")); + assertTrue(power.contains(".isLocked()")); + assertTrue(power.contains("locked route delivers no stock")); + assertTrue(power.contains("locked route retains every input item")); + String boiler = source("SteamOperationalCases.java"); + assertTrue(boiler.contains("boiler.prepareDataFieldsForSync()")); + assertTrue(boiler.contains("boiler.getDataFieldArray()")); + assertTrue(boiler.contains("oilChargeBurnedOut(counters[2], counters[3], c.nbt(X, Y, Z))")); + assertFalse(boiler.contains("getInt(\"BurnTime\").orElse")); + String musket = source("SteamOperationalActors.java"); + assertTrue(musket.contains("InteractionResult reload = weapon.use(")); + assertTrue(musket.contains("InteractionResult retry = shot.weapon.getItem().use(")); + assertTrue(musket.contains("shot.player.isUsingItem()")); + assertTrue(musket.contains("sameInventory(shot.player.getInventory(), shot.unladenInventory)")); + assertTrue(musket.contains("getByte(\"loaded\").orElseThrow() == 0")); + assertFalse(musket.contains("putByte(\"loaded\"")); + assertFalse(musket.contains("getByteOr(\"loaded\"")); + assertTrue(musket.contains("shot.target.getHealth() - (1000 - shot.expectedDamage)")); + assertTrue(musket.contains("shot.weapon.getDamageValue() == (mode.equals(\"no-ammo\") ? 0 : 1)")); + } + + private static CompoundTag completedBurn() { + CompoundTag saved = new CompoundTag(); + saved.putInt("BurnTimeTotal", 5); + return saved; + } + + private static ChestBlockEntity chest() throws Exception { + ChestBlockEntity chest = new ChestBlockEntity(BlockPos.ZERO, Blocks.CHEST.defaultBlockState()); + chest.setItem(0, new ItemStack(Items.DIRT, 16)); + chest.setLevel(Native117EntityContextTest.world(Level.OVERWORLD)); + return chest; + } + + private static String source(String name) throws Exception { + return Files.readString(Path.of("src/main/java/zone/moddev/mc/advantageworks", name)); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111ProgressionProbeTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111ProgressionProbeTest.java new file mode 100644 index 00000000..1d53d0c8 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111ProgressionProbeTest.java @@ -0,0 +1,97 @@ +package zone.moddev.mc.advantageworks; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.HolderSet; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.PlacementInfo; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.RecipeBookCategories; +import net.minecraft.world.item.crafting.RecipeBookCategory; +import net.minecraft.world.item.crafting.RecipeSerializer; +import net.minecraft.world.item.crafting.RecipeType; +import net.minecraft.world.item.crafting.SingleRecipeInput; +import net.minecraft.world.level.Level; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class Native12111ProgressionProbeTest { + @BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + } + + @Test public void nativePlacementPreservesHolesOrderAndRepeatedInputs() { + PlacementInfo placement = PlacementInfo.createFromOptionals(List.of( + Optional.of(Ingredient.of(Items.POTATO)), Optional.empty(), + Optional.of(Ingredient.of(Items.CARROT)), Optional.of(Ingredient.of(Items.POTATO)))); + List slots = ProgressionProbe.knownIngredients(placement, + Set.of("minecraft:potato", "minecraft:carrot")); + assertNotNull(slots); + assertEquals(4, slots.size()); + assertTrue(slots.get(0).is(Items.POTATO)); + assertTrue(slots.get(1).isEmpty()); + assertTrue(slots.get(2).is(Items.CARROT)); + assertTrue(slots.get(3).is(Items.POTATO)); + assertNotSame(slots.get(0), slots.get(3)); + assertEquals(1, slots.get(0).getCount()); + assertEquals(1, slots.get(3).getCount()); + } + + @Test public void undiscoveredIngredientsCannotEnterProgression() { + assertNull(ProgressionProbe.knownIngredients(PlacementInfo.create(Ingredient.of(Items.CARROT)), + Set.of("minecraft:potato"))); + assertNull(ProgressionProbe.knownIngredients(PlacementInfo.create(Ingredient.of(Items.POTATO)), Set.of())); + } + + @Test public void nativeIngredientPredicateCannotBeReplacedWithItemMembership() { + Ingredient rejecting = new Ingredient(HolderSet.direct(Items.POTATO.builtInRegistryHolder())) { + @Override public boolean test(ItemStack candidate) { return false; } + }; + assertTrue(rejecting.acceptsItem(Items.POTATO.builtInRegistryHolder())); + assertNull(ProgressionProbe.knownIngredients(PlacementInfo.create(rejecting), Set.of("minecraft:potato"))); + } + + @Test public void knownAlternativeIsTestedNatively() { + List slots = ProgressionProbe.knownIngredients( + PlacementInfo.create(Ingredient.of(Items.POTATO, Items.CARROT)), Set.of("minecraft:carrot")); + assertEquals(1, slots.size()); + assertTrue(slots.getFirst().is(Items.CARROT)); + } + + @Test public void impossiblePlacementDoesNotInventIngredients() { + assertTrue(ProgressionProbe.knownIngredients(PlacementInfo.NOT_PLACEABLE, Set.of("minecraft:potato")).isEmpty()); + } + + @Test public void specialSingleInputRecipesUseMatchesDespiteHavingNoBookPlacement() { + Recipe recipe = specialRecipe(); + assertTrue(recipe.placementInfo().isImpossibleToPlace()); + List inputs = ProgressionProbe.knownSingleInput(recipe, Set.of("minecraft:potato"), null); + assertEquals(1, inputs.size()); + assertTrue(inputs.getFirst().is(Items.POTATO)); + assertEquals(1, inputs.getFirst().getCount()); + assertTrue(ProgressionProbe.knownSingleInput(recipe, Set.of("minecraft:carrot"), null).isEmpty()); + assertTrue(ProgressionProbe.knownSingleInput(recipe, Set.of(), null).isEmpty()); + } + + private static Recipe specialRecipe() { + return new Recipe<>() { + @Override public boolean matches(SingleRecipeInput input, Level world) { + return input.item().is(Items.POTATO) && input.item().getCount() == 1; + } + @Override public ItemStack assemble(SingleRecipeInput input, HolderLookup.Provider registries) { + return new ItemStack(Items.CARROT); + } + @Override public RecipeSerializer> getSerializer() { return RecipeSerializer.SMELTING_RECIPE; } + @Override public RecipeType> getType() { return RecipeType.SMELTING; } + @Override public boolean isSpecial() { return true; } + @Override public PlacementInfo placementInfo() { return PlacementInfo.NOT_PLACEABLE; } + @Override public RecipeBookCategory recipeBookCategory() { return RecipeBookCategories.FURNACE_FOOD; } + }; + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111RecipeBookProbeTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111RecipeBookProbeTest.java new file mode 100644 index 00000000..0cc8a5c8 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native12111RecipeBookProbeTest.java @@ -0,0 +1,108 @@ +package zone.moddev.mc.advantageworks; + +import io.netty.buffer.Unpooled; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.common.ClientboundUpdateTagsPacket; +import net.minecraft.tags.TagNetworkSerialization; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.network.protocol.game.ClientboundRecipeBookAddPacket; +import net.minecraft.util.context.ContextMap; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeBookCategories; +import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; +import net.minecraft.world.item.crafting.display.RecipeDisplayId; +import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplayContext; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class Native12111RecipeBookProbeTest { + private static final ContextMap CONTEXT = new ContextMap.Builder().create(SlotDisplayContext.CONTEXT); + + @BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + } + + @Test public void nativeDisplayRetainsOutputCountAndIdentity() { + ItemStack expected = new ItemStack(Items.CARROT, 3); + assertTrue(ItemStack.matches(expected, + RecipeBookProbe.displayResult(display(new SlotDisplay.ItemStackSlotDisplay(expected)), CONTEXT))); + } + + @Test public void emptyDisplayFailsClosed() { + assertThrows(IllegalStateException.class, () -> RecipeBookProbe.displayResult(display(SlotDisplay.Empty.INSTANCE), CONTEXT)); + } + + @Test public void ambiguousDisplayCannotSelectOnlyItsFirstOutput() { + SlotDisplay mixed = new SlotDisplay.Composite(List.of( + new SlotDisplay.ItemSlotDisplay(Items.CARROT), new SlotDisplay.ItemSlotDisplay(Items.POTATO))); + assertThrows(IllegalStateException.class, () -> RecipeBookProbe.displayResult(display(mixed), CONTEXT)); + } + + @Test public void nativeBookPacketRoundTripsDisplayIdsGroupsAndIngredients() { + RecipeDisplayEntry expected = display(new SlotDisplay.ItemStackSlotDisplay(new ItemStack(Items.CARROT, 3))); + var entries = List.of(new ClientboundRecipeBookAddPacket.Entry(expected, false, false)); + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), + RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY)); + try { + ClientboundRecipeBookAddPacket.STREAM_CODEC.encode(buffer, new ClientboundRecipeBookAddPacket(entries, true)); + ClientboundRecipeBookAddPacket decoded = ClientboundRecipeBookAddPacket.STREAM_CODEC.decode(buffer); + assertFalse(buffer.isReadable()); + assertTrue(decoded.replace()); + assertEquals(entries, decoded.entries()); + RecipeDisplayEntry actual = decoded.entries().getFirst().contents(); + assertEquals(7, actual.id().index()); + assertEquals(3, actual.group().orElseThrow()); + assertTrue(actual.craftingRequirements().orElseThrow().getFirst().test(new ItemStack(Items.POTATO))); + assertFalse(actual.craftingRequirements().orElseThrow().getFirst().test(new ItemStack(Items.CARROT))); + assertTrue(ItemStack.matches(new ItemStack(Items.CARROT, 3), RecipeBookProbe.displayResult(actual, CONTEXT))); + } finally { buffer.release(); } + } + + @Test public void nativeTagPacketRetainsEveryTagAndEveryRealItemMember() { + FriendlyByteBuf payload = new FriendlyByteBuf(Unpooled.buffer()); + byte[] encoded; + var tag = Identifier.parse("advantageworks:recipe_probe"); + try { + payload.writeMap(java.util.Map.of(tag, new it.unimi.dsi.fastutil.ints.IntArrayList(new int[] { + BuiltInRegistries.ITEM.getId(Items.POTATO), BuiltInRegistries.ITEM.getId(Items.CARROT)})), + FriendlyByteBuf::writeIdentifier, FriendlyByteBuf::writeIntIdList); + var nativeTags = TagNetworkSerialization.NetworkPayload.read(payload); + assertFalse(payload.isReadable()); + encoded = RecipeBookProbe.itemTagsPacket(java.util.Map.of(Registries.ITEM, nativeTags)); + } finally { payload.release(); } + FriendlyByteBuf packet = new FriendlyByteBuf(Unpooled.wrappedBuffer(encoded)); + try { + var decoded = ClientboundUpdateTagsPacket.STREAM_CODEC.decode(packet); + assertFalse(packet.isReadable()); + var tags = decoded.getTags().get(Registries.ITEM); + assertEquals(1, tags.size()); + assertEquals(List.of(Items.POTATO, Items.CARROT), tags.resolve(BuiltInRegistries.ITEM).tags() + .get(net.minecraft.tags.TagKey.create(Registries.ITEM, tag)).stream() + .map(net.minecraft.core.Holder::value).toList()); + } finally { packet.release(); } + } + + @Test public void recipeExportCannotPretendAnAbsentItemTagPacketIsComplete() { + assertThrows(IllegalStateException.class, () -> RecipeBookProbe.itemTagsPacket(java.util.Map.of())); + } + + private static RecipeDisplayEntry display(SlotDisplay result) { + return new RecipeDisplayEntry(new RecipeDisplayId(7), new ShapelessCraftingRecipeDisplay( + List.of(new SlotDisplay.ItemSlotDisplay(Items.POTATO)), result, + new SlotDisplay.ItemSlotDisplay(Items.CRAFTING_TABLE)), OptionalInt.of(3), + RecipeBookCategories.CRAFTING_MISC, Optional.of(List.of(Ingredient.of(Items.POTATO)))); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeActorIoTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeActorIoTest.java new file mode 100644 index 00000000..1421854f --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeActorIoTest.java @@ -0,0 +1,82 @@ +package zone.moddev.mc.advantageworks; + +import static org.junit.Assert.*; + +import com.mojang.serialization.Codec; +import net.minecraft.core.UUIDUtil; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.TagValueOutput; +import net.minecraft.world.level.storage.ValueOutput; +import org.junit.BeforeClass; +import org.junit.Test; + +public class NativeActorIoTest { + @BeforeClass public static void bootstrap() { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + } + + @Test public void nativeValueIoRoundTripRetainsIdentityInventoryAndForgeData() throws Exception { + ServerLevel world = Native117EntityContextTest.world(Level.NETHER); + ItemEntity original = new ItemEntity(world, 8.5, 80, 8.5, new ItemStack(Items.DIRT, 7)); + original.getPersistentData().putString("witness", "keep exactly"); + CompoundTag saved = NativeActorIo.save(original); + assertEquals("minecraft:item", saved.getString("id").orElseThrow()); + assertEquals(original.getUUID(), saved.read("UUID", UUIDUtil.CODEC).orElseThrow()); + assertFalse(NativeActorIo.saveWithoutId(original).contains("id")); + ItemEntity loaded = new ItemEntity(EntityType.ITEM, world); + NativeActorIo.load(loaded, saved); + assertEquals(saved, NativeActorIo.save(loaded)); + assertEquals(7, loaded.getItem().getCount()); + } + + @Test public void rootRefusalDoesNotBecomeAPartialWitness() throws Exception { + ServerLevel world = Native117EntityContextTest.world(Level.END); + var root = Native117EntityContextTest.boat(world); + var passenger = Native117EntityContextTest.boat(world); + assertTrue(passenger.startRiding(root, true, false)); + assertThrows(IllegalStateException.class, () -> NativeActorIo.save(passenger)); + assertEquals(passenger.getUUID(), NativeActorIo.saveAsPassenger(passenger).read("UUID", UUIDUtil.CODEC).orElseThrow()); + assertSame(root, passenger.getVehicle()); + } + + @Test public void codecEncodeProblemThrowsWithItsNestedPath() throws Exception { + ServerLevel world = Native117EntityContextTest.world(Level.OVERWORLD); + ItemEntity broken = new ItemEntity(EntityType.ITEM, world) { + @Override public void saveWithoutId(ValueOutput output) { + output.child("outer").store("broken", Codec.intRange(0, 1), 2); + } + }; + IllegalStateException error = assertThrows(IllegalStateException.class, () -> NativeActorIo.saveWithoutId(broken)); + assertTrue(error.getMessage(), error.getMessage().contains(".outer")); + assertTrue(error.getMessage(), error.getMessage().contains("broken")); + } + + @Test public void codecDecodeProblemCannotFallBackToAnEmptyPersistentCompound() throws Exception { + ServerLevel world = Native117EntityContextTest.world(Level.OVERWORLD); + ItemEntity item = new ItemEntity(world, 8.5, 80, 8.5, new ItemStack(Items.DIRT, 7)); + CompoundTag malformed = NativeActorIo.save(item); + malformed.putString("ForgeData", "not a compound"); + RuntimeException error = assertThrows(RuntimeException.class, () -> NativeActorIo.load(item, malformed)); + assertTrue(error.toString(), causes(error).contains("Native actor persistence problem")); + assertTrue(error.toString(), causes(error).contains("ForgeData")); + } + + @Test public void reporterRejectsAPartialOutputInsteadOfReturningIt() { + TagValueOutput output = TagValueOutput.createWithoutContext(NativeActorIo.reporter("control")); + assertThrows(IllegalStateException.class, () -> output.store("bad", Codec.intRange(0, 1), 7)); + assertFalse(output.buildResult().contains("bad")); + } + + private static String causes(Throwable error) { + StringBuilder result = new StringBuilder(); + for (Throwable cause = error; cause != null; cause = cause.getCause()) result.append(cause).append('\n'); + return result.toString(); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeEntityReadSelectorsTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeEntityReadSelectorsTest.java index eeed0fbb..54254c06 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeEntityReadSelectorsTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeEntityReadSelectorsTest.java @@ -12,8 +12,9 @@ import net.minecraft.server.Bootstrap; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.vehicle.Boat; +import net.minecraft.world.entity.vehicle.boat.Boat; import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueOutput; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; @@ -59,7 +60,7 @@ public NativeEntityReadSelectorsTest(String selector, String scenario) { if (scenario.equals("otherValue")) data.putString(key, "another station"); field.set(entity, data); entity.persistentReads = 0; - CompoundTag before = entity.saveWithoutId(new CompoundTag()).copy(); + CompoundTag before = NativeActorIo.saveWithoutId(entity).copy(); int ticks = entity.tickCount; for (int observation = 0; observation < 2; observation++) { int serializations = entity.serializations; @@ -76,7 +77,7 @@ public NativeEntityReadSelectorsTest(String selector, String scenario) { assertSame("Rejected and accepted entities must retain the exact persistent-data field", data, field.get(entity)); assertEquals("Read selectors must never call the allocating getter", 0, entity.persistentReads); assertEquals("Selection must not serialize an entity or invoke its save callbacks", serializations, entity.serializations); - assertEquals(before, entity.saveWithoutId(new CompoundTag())); + assertEquals(before, NativeActorIo.saveWithoutId(entity)); assertEquals(data != null, before.contains("ForgeData")); assertEquals(ticks, entity.tickCount); assertFalse(entity.isRemoved()); @@ -86,8 +87,8 @@ public NativeEntityReadSelectorsTest(String selector, String scenario) { private static final class ObservedBoat extends Boat { int persistentReads; int serializations; - ObservedBoat(Level world) { super(EntityType.BOAT, world); } + ObservedBoat(Level world) { super(EntityType.OAK_BOAT, world, () -> net.minecraft.world.item.Items.OAK_BOAT); } @Override public CompoundTag getPersistentData() { persistentReads++; return super.getPersistentData(); } - @Override public CompoundTag saveWithoutId(CompoundTag tag) { serializations++; return super.saveWithoutId(tag); } + @Override public void saveWithoutId(ValueOutput output) { serializations++; super.saveWithoutId(output); } } } diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeEntityTickGateTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeEntityTickGateTest.java index 344bfdee..f8130fb5 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeEntityTickGateTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeEntityTickGateTest.java @@ -7,7 +7,7 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.vehicle.Boat; +import net.minecraft.world.entity.vehicle.boat.Boat; import net.minecraft.world.level.Level; import net.minecraft.world.level.entity.EntityTickList; import org.junit.BeforeClass; @@ -22,7 +22,7 @@ public class NativeEntityTickGateTest { ServerLevel world = Native117EntityContextTest.world(Level.OVERWORLD); java.lang.reflect.Field field = ServerLevel.class.getDeclaredField("entityTickList"); field.setAccessible(true); EntityTickList original = (EntityTickList) field.get(world); - Boat first = new Boat(EntityType.BOAT, world), second = new Boat(EntityType.BOAT, world); + Boat first = Native117EntityContextTest.boat(world), second = Native117EntityContextTest.boat(world); original.add(first); original.add(second); NativeEntityHold hold = new NativeEntityHold(); hold.hold(first); @@ -39,7 +39,7 @@ public class NativeEntityTickGateTest { @Test public void joinBeforeNativeRegistrationIsStillHeldAndAllOtherOperationsDelegate() throws Exception { ServerLevel world = Native117EntityContextTest.world(Level.OVERWORLD); - Boat first = new Boat(EntityType.BOAT, world), other = new Boat(EntityType.BOAT, world); + Boat first = Native117EntityContextTest.boat(world), other = Native117EntityContextTest.boat(world); NativeEntityHold hold = new NativeEntityHold(); hold.hold(first); NativeEntityTickGate gate = NativeEntityTickGate.of(world); gate.add(first); gate.add(other); @@ -53,7 +53,7 @@ public class NativeEntityTickGateTest { @Test public void overlappingHoldsReleaseOnlyTheirOwnNativeGate() throws Exception { ServerLevel world = Native117EntityContextTest.world(Level.OVERWORLD); - Boat boat = new Boat(EntityType.BOAT, world); + Boat boat = Native117EntityContextTest.boat(world); NativeEntityTickGate gate = NativeEntityTickGate.of(world); gate.add(boat); NativeEntityHold first = new NativeEntityHold(), second = new NativeEntityHold(); first.hold(boat); second.hold(boat); diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorldgenProtocolContractTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorldgenProtocolContractTest.java index b8355f28..6de3f8c7 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorldgenProtocolContractTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorldgenProtocolContractTest.java @@ -9,8 +9,8 @@ public class WorldgenProtocolContractTest { @Test public void samplingAdvancesOnlyFromNativeServerTickEnd() throws Exception { String source = source("src/main/java/zone/moddev/mc/advantageworks/WorksController.java"); - assertTrue("Sampling must yield to native server ticks", source.contains("TickEvent.ServerTickEvent")); - assertTrue(source.contains("TickEvent.Phase.END")); + assertTrue("Sampling must yield to native server ticks", source.contains("NativeTickEvents.ServerTickEvent")); + assertTrue(source.contains("NativeTickEvents.Phase.END")); String command = source.substring(source.indexOf("synchronized String sampleWorldgen("), source.indexOf("List stationIdsWithAll()")); assertFalse("The command must not synchronously populate the halo", diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native12111ProfileTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native12111ProfileTest.java new file mode 100644 index 00000000..05ca0042 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native12111ProfileTest.java @@ -0,0 +1,378 @@ +package zone.moddev.mc.advantageworks.runner; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class Native12111ProfileTest { + private static final Gson GSON = new Gson(); + private static final String FORGE = "1.21.11-61.1.0"; + private static final String SHIM = "forge-" + FORGE + "-shim.jar"; + private static final String LIBRARY = "libraries/native/runtime.jar"; + @Rule public TemporaryFolder temporary = new TemporaryFolder(); + + private interface Action { void run() throws Exception; } + + private static Exception rejected(Action action) throws Exception { + try { action.run(); } + catch (Exception failure) { return failure; } + throw new AssertionError("Unsafe Forge 61 input was accepted"); + } + + private static String hash(Path path) throws Exception { + StringBuilder result = new StringBuilder(); + for (byte value : MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path))) { + result.append(String.format(Locale.ROOT, "%02X", value & 255)); + } + return result.toString(); + } + + private File javaHome(String version) throws Exception { + File home = temporary.newFolder("java with spaces " + System.nanoTime()); + Files.createDirectory(home.toPath().resolve("bin")); + String executable = System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win") ? "java.exe" : "java"; + Files.write(home.toPath().resolve("bin/" + executable), new byte[] {1}); + Files.writeString(home.toPath().resolve("release"), "JAVA_VERSION=\"" + version + "\"\n"); + return home; + } + + private JsonObject profile() throws Exception { + Path rig = temporary.newFolder("rig " + System.nanoTime()).toPath(); + Files.createDirectory(rig.resolve("profiles")); + Files.createDirectories(rig.resolve("src/main/resources/assets/advantageworkstest")); + Files.write(rig.resolve("build.gradle"), new byte[0]); + JsonObject profile = new JsonObject(); + profile.addProperty("name", "native12111-test"); + profile.addProperty("mode", "packaged"); + profile.addProperty("minecraftVersion", "1.21.11"); + profile.addProperty("runtimeBase", "build/works-native12111"); + profile.addProperty("portStatus", "PORTED_UNQUALIFIED"); + profile.addProperty("qualificationStatus", "EXPLORATORY_UNQUALIFIED"); + profile.addProperty("java21Home", javaHome("21.0.7").getAbsolutePath()); + JsonObject forge = new JsonObject(); + forge.addProperty("version", FORGE); + profile.add("forge", forge); + JsonArray mods = new JsonArray(); + profile.add("mods", mods); + String powerHash = null; + for (String id : Arrays.asList("basepower", "advantageworkstest", "orespawn")) { + String version = id.equals("basepower") ? "2.4.15.121111" + : id.equals("advantageworkstest") ? "1.1.5.121111" : "4.1.0.121111"; + Path jar = rig.resolve(id + "-" + version + ".jar"); + writeMod(jar, id, version, powerHash); + JsonObject mod = new JsonObject(); + mod.addProperty("id", id); + mod.addProperty("version", version); + mod.addProperty("path", jar.toString()); + mod.addProperty("file", jar.getFileName().toString()); + mod.addProperty("sha256", hash(jar)); + mods.add(mod); + if (id.equals("basepower")) powerHash = hash(jar); + } + return profile; + } + + private static void writeMod(Path path, String id, String version, String powerHash) throws Exception { + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.putValue("Manifest-Version", "1.0"); + if (powerHash != null) { + attributes.putValue("Built-On", FORGE); + attributes.putValue("Power-Input-Status", "EXPLORATORY_UNQUALIFIED"); + attributes.putValue("Power-Input-SHA256", powerHash); + } + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(path), manifest)) { + output.putNextEntry(new JarEntry("META-INF/mods.toml")); + output.write(("[[mods]]\nmodId=\"" + id + "\"\nversion=\"" + version + "\"\n") + .getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + + private static Path rig(JsonObject profile) { + return Path.of(profile.getAsJsonArray("mods").get(0).getAsJsonObject().get("path").getAsString()).getParent(); + } + + private static File save(JsonObject profile) throws Exception { + Path path = rig(profile).resolve("profiles/native12111.json"); + Files.writeString(path, profile.toString()); + return path.toFile(); + } + + private static void validate(JsonObject profile) throws Exception { + // Exercise the entry point in-process without changing its inherited environment. + WorksRunner.main(new String[] {"--profile", save(profile).getAbsolutePath(), "--validate-only"}); + } + + private static WorksRunner.Profile parsed(JsonObject profile) { + return GSON.fromJson(profile, WorksRunner.Profile.class); + } + + @Test public void validateOnlyAcceptsMaterializedPythonProfileWithoutOpeningAWorld() throws Exception { + JsonObject profile = profile(); + profile.addProperty("levelType", "minecraft:flat"); + validate(profile); + assertFalse(Files.exists(rig(profile).resolve("build"))); + assertFalse(Files.exists(rig(profile).resolve("AdvantageWorks"))); + } + + @Test public void native61PinsEmptyServerPauseOffEvenWhenPropertiesAlreadyExist() throws Exception { + WorksRunner.Profile profile = parsed(profile()); + Path runtime = temporary.newFolder().toPath(); + Path propertiesFile = runtime.resolve("server.properties"); + Files.writeString(propertiesFile, "pause-when-empty-seconds=60\n"); + WorksRunner.writeRuntimeFiles(runtime.toFile(), profile); + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(propertiesFile)) { properties.load(input); } + assertEquals("-1", properties.getProperty("pause-when-empty-seconds")); + assertEquals(1L, Files.readAllLines(propertiesFile).stream() + .filter(line -> line.startsWith("pause-when-empty-seconds=")).count()); + assertFalse(Files.exists(runtime.resolve(profile.levelName))); + } + + @Test public void olderVersionsDoNotAddAnEmptyServerPauseOverride() throws Exception { + WorksRunner.Profile profile = parsed(profile()); + for (String minecraft : Arrays.asList("1.10.2", "1.12.2", "1.13.2", "1.14.4", "1.15.2", "1.16.5", + "1.17.1", "1.18.2", "1.19.4", "1.20.1", "1.20.6", "1.21.1")) { + profile.minecraftVersion = minecraft; + Path runtime = temporary.newFolder().toPath(); + WorksRunner.writeRuntimeFiles(runtime.toFile(), profile); + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(runtime.resolve("server.properties"))) { properties.load(input); } + assertFalse(minecraft, properties.containsKey("pause-when-empty-seconds")); + assertFalse(minecraft, Files.readString(runtime.resolve("server.properties")).contains("pause-when-empty-seconds")); + } + } + + @Test public void rejectsUnportedQualifiedDevelopmentAndWrongForgeProfiles() throws Exception { + for (String field : Arrays.asList("portStatus", "qualificationStatus", "mode", "forge", "runtimeBase")) { + JsonObject profile = profile(); + if (field.equals("forge")) profile.getAsJsonObject("forge").addProperty("version", "1.21.11-61.1.1"); + else profile.addProperty(field, field.equals("mode") ? "development" : "PASS"); + String message = rejected(() -> validate(profile)).getMessage(); + assertTrue(message, message.contains(field.equals("forge") ? "must select Forge" + : field.equals("portStatus") ? "not ported" + : field.equals("runtimeBase") ? "isolated build/works-native12111" : "ordinary pinned packaged artifacts")); + assertFalse(Files.exists(rig(profile).resolve("build"))); + } + } + + @Test public void strictOrdinaryInputsRejectMissingDuplicateAndBypassedIdentities() throws Exception { + for (String mutation : Arrays.asList("duplicate", "missing", "target", "file", "optional", "identity", "workspace")) { + JsonObject profile = profile(); + JsonArray mods = profile.getAsJsonArray("mods"); + JsonObject mod = mods.get(2).getAsJsonObject(); + if (mutation.equals("duplicate")) mods.add(GSON.fromJson(mod, JsonObject.class)); + if (mutation.equals("missing")) mods.remove(2); + if (mutation.equals("target")) mod.addProperty("version", "4.1.0.121011"); + if (mutation.equals("file")) mod.addProperty("file", "OreSpawn-4.1.0.121111-deobf.jar"); + if (mutation.equals("optional")) mod.addProperty("required", false); + if (mutation.equals("identity")) mod.addProperty("verifyIdentity", false); + if (mutation.equals("workspace")) mod.addProperty("workspaceProduced", true); + String message = rejected(() -> validate(profile)).getMessage(); + assertTrue(message, message.contains(mutation.equals("missing") + ? "must be explicitly pinned" : "Invalid ordinary native mod input")); + assertFalse(Files.exists(rig(profile).resolve("build"))); + } + } + + @Test public void rejectsUnpinnedChangedAndMisidentifiedModBytes() throws Exception { + for (String mutation : Arrays.asList("pin", "bytes", "identity", "compile-pin")) { + JsonObject profile = profile(); + JsonObject mod = profile.getAsJsonArray("mods").get(mutation.equals("compile-pin") ? 1 : 2).getAsJsonObject(); + Path jar = Path.of(mod.get("path").getAsString()); + if (mutation.equals("pin")) mod.remove("sha256"); + if (mutation.equals("bytes")) Files.write(jar, new byte[] {1}); + if (mutation.equals("identity") || mutation.equals("compile-pin")) { + writeMod(jar, mutation.equals("identity") ? "not_orespawn" : "advantageworkstest", + mod.get("version").getAsString(), mutation.equals("compile-pin") ? "0".repeat(64) : null); + mod.addProperty("sha256", hash(jar)); + } + String message = rejected(() -> validate(profile)).getMessage(); + assertTrue(message, message.contains(mutation.equals("pin") ? "SHA-256 pin" + : mutation.equals("bytes") ? "SHA-256 mismatch" + : mutation.equals("identity") ? "Mod identity orespawn not found" : "same ordinary jar")); + assertFalse(Files.exists(rig(profile).resolve("build"))); + } + } + + @Test public void requiresJava21AndCannotOverrideThePinnedCommand() throws Exception { + for (String version : Arrays.asList("8.0.502", "17.0.1", "25.0.1")) { + JsonObject profile = profile(); + profile.addProperty("java21Home", javaHome(version).getAbsolutePath()); + assertTrue(rejected(() -> validate(profile)).getMessage().contains("Java 21")); + } + JsonObject profile = profile(); + JsonArray command = new JsonArray(); + command.add("java"); + command.add("-javaagent:unverified.jar"); + profile.add("launchCommand", command); + assertTrue(rejected(() -> validate(profile)).getMessage().contains("cannot override")); + } + + @Test public void usesForge61ModularLauncherOnBothPlatforms() throws Exception { + WorksRunner.Profile profile = parsed(profile()); + String os = System.getProperty("os.name"); + try { + for (String platform : Arrays.asList("Windows 11", "Linux")) { + System.setProperty("os.name", platform); + assertEquals("libraries/net/minecraftforge/forge/" + FORGE + "/" + + (platform.startsWith("Windows") ? "win_args.txt" : "unix_args.txt"), + WorksRunner.serverLauncherPath(profile)); + } + } finally { System.setProperty("os.name", os); } + } + + @Test public void rejectsInheritedJvmAgentsAndNestedArgumentsOnlyForForge61() throws Exception { + WorksRunner.requireNative12111Environment(Map.of()); + for (String variable : Arrays.asList("JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS")) { + for (String options : Arrays.asList("-javaagent:outside.jar", "-agentpath:outside.dll", "@outside.args")) { + String message = rejected(() -> WorksRunner.requireNative12111Environment(Map.of(variable, options))).getMessage(); + assertTrue(message, message.contains(variable)); + } + } + } + + @Test public void rejectsInheritedProcessorLimitsAndOtherJvmOverrides() throws Exception { + for (String variable : Arrays.asList("JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS")) { + for (String options : Arrays.asList("-XX:ActiveProcessorCount=4", "-XX:ActiveProcessorCount=4 -javaagent:outside.jar", + "-XX:ActiveProcessorCount=4 @outside.args", "-XX:ActiveProcessorCount=4\n-agentlib:outside", + "-XX:ActiveProcessorCount=4 -Dunexpected=1", "-XX:ActiveProcessorCount=0")) { + assertTrue(rejected(() -> WorksRunner.requireNative12111Environment(Map.of(variable, options))) + .getMessage().contains(variable)); + } + } + } + + @Test public void oldShimProfileReadersAndLaunchersKeepTheirOriginalTargets() throws Exception { + for (String minecraft : Arrays.asList("1.20.6", "1.21.1")) { + JsonObject profile = profile(); + String suffix = minecraft.equals("1.20.6") ? ".120061" : ".121011"; + String forge = minecraft.equals("1.20.6") ? "1.20.6-50.2.0" : "1.21.1-52.1.0"; + profile.addProperty("minecraftVersion", minecraft); + profile.getAsJsonObject("forge").addProperty("version", forge); + for (var element : profile.getAsJsonArray("mods")) { + JsonObject mod = element.getAsJsonObject(); + for (String field : Arrays.asList("version", "file")) { + mod.addProperty(field, mod.get(field).getAsString().replace(".121111", suffix)); + } + } + File file = save(profile); + WorksRunner.Profile legacy = minecraft.equals("1.20.6") + ? WorksRunner.readNative1206Profile(file) : WorksRunner.readRunnerProfile(file); + assertEquals(minecraft, legacy.minecraftVersion); + assertTrue(WorksRunner.serverLauncherPath(legacy).startsWith("libraries/net/minecraftforge/forge/" + forge + "/")); + assertTrue(rejected(() -> WorksRunner.readNative12111Profile(file)).getMessage().contains("Historical source profile")); + } + } + + private Path seed(JsonObject profile) throws Exception { + Path seed = temporary.newFolder("seed " + System.nanoTime()).toPath(); + String launcher = WorksRunner.serverLauncherPath(parsed(profile)); + Files.createDirectories(seed.resolve(launcher).getParent()); + Files.createDirectories(seed.resolve(LIBRARY).getParent()); + Files.writeString(seed.resolve(launcher), "-Djava.net.preferIPv6Addresses=system -jar " + SHIM + "\n"); + Files.write(seed.resolve(SHIM), new byte[] {3, 4}); + Files.write(seed.resolve(LIBRARY), new byte[] {5, 6}); + return seed; + } + + private Path inventory(Path seed, JsonObject profile, String forge) throws Exception { + JsonObject inventory = new JsonObject(); + JsonObject files = new JsonObject(); + inventory.add("files", files); + for (String name : Arrays.asList(SHIM, LIBRARY, WorksRunner.serverLauncherPath(parsed(profile)))) { + JsonObject row = new JsonObject(); + row.addProperty("sha256", hash(seed.resolve(name))); + row.addProperty("size", Files.size(seed.resolve(name))); + files.add(name, row); + } + if (forge != null) inventory.addProperty("forge", forge); + Path path = temporary.newFile().toPath(); + Files.writeString(path, inventory.toString()); + return path; + } + + private WorksRunner.PreparedServerSeed load(Path seed, Path inventory, JsonObject profile) throws Exception { + return WorksRunner.preparedServerSeed(temporary.getRoot(), parsed(profile), WorksRunner.Arguments.parse( + new String[] {"--profile", "p", "--prepared-server-seed", seed.toString(), + "--prepared-server-seed-inventory", inventory.toString(), "--prepared-server-seed-sha256", hash(inventory)})); + } + + @Test public void stagesSealedForge61CodeAndAllowsOnlyTheOfficialInstallerScaffolding() throws Exception { + JsonObject profile = profile(); + Path seed = seed(profile); + Path inventory = inventory(seed, profile, FORGE); + for (String name : Arrays.asList("README.txt", "run.bat", "run.sh", "user_jvm_args.txt", + "forge-" + FORGE + "-installer.jar.log")) Files.writeString(seed.resolve(name), "installer scaffolding"); + WorksRunner.PreparedServerSeed prepared = load(seed, inventory, profile); + Path runtime = temporary.newFolder().toPath(); + prepared.stage(runtime.toFile()); + assertArrayEquals(Files.readAllBytes(seed.resolve(SHIM)), Files.readAllBytes(runtime.resolve(SHIM))); + assertArrayEquals(Files.readAllBytes(inventory), + Files.readAllBytes(runtime.resolve(WorksRunner.PreparedServerSeed.INVENTORY))); + assertFalse(Files.exists(runtime.resolve("run.bat"))); + prepared.verifySource(); + Files.write(runtime.resolve(LIBRARY), new byte[] {9}); + rejected(() -> prepared.verifyTree(runtime, false)); + Files.write(seed.resolve(LIBRARY), new byte[] {9}); + rejected(prepared::verifySource); + } + + @Test public void rejectsMissingWrongOrDuplicateForgeInventoryMetadataAndExtraCode() throws Exception { + for (String forge : Arrays.asList(null, "1.21.1-52.1.0")) { + JsonObject profile = profile(); + Path seed = seed(profile), inventory = inventory(seed, profile, forge); + rejected(() -> load(seed, inventory, profile)); + } + JsonObject profile = profile(); + Path seed = seed(profile), duplicateInventory = inventory(seed, profile, FORGE); + Files.writeString(duplicateInventory, Files.readString(duplicateInventory).replace("\"forge\":", "\"forge\":\"" + FORGE + "\",\"forge\":")); + rejected(() -> load(seed, duplicateInventory, profile)); + Path receipt = inventory(seed, profile, FORGE); + Files.write(seed.resolve("unlisted.jar"), new byte[] {1}); + rejected(() -> load(seed, receipt, profile)); + } + + @Test public void rejectsAgentNestedArgfileWrongShimAndModifiedOfficialShimBytes() throws Exception { + JsonObject profile = profile(); + for (String command : Arrays.asList("-Djava.net.preferIPv6Addresses=system -jar " + SHIM, + "-javaagent:outside.jar -jar " + SHIM, "@outside.txt", "-jar forge-1.21.1-52.1.0-shim.jar")) { + Path runtime = seed(profile); + Files.writeString(runtime.resolve(WorksRunner.serverLauncherPath(parsed(profile))), command); + rejected(() -> WorksRunner.requireModularLauncher(runtime.toFile(), WorksRunner.serverLauncherPath(parsed(profile)))); + } + } + + @Test public void installerFallbackRetainsExactHashCustodyWithoutStartingAProcess() throws Exception { + JsonObject profile = profile(); + Path installer = rig(profile).resolve("forge-" + FORGE + "-installer.jar"); + Files.write(installer, new byte[] {7}); + JsonObject forge = profile.getAsJsonObject("forge"); + forge.addProperty("installerPath", installer.toString()); + forge.addProperty("sha256", hash(installer)); + Path runtime = seed(profile); + WorksRunner.preparePackagedServer(rig(profile).toFile(), runtime.toFile(), parsed(profile)); + Files.write(installer, new byte[] {8}); + assertTrue(rejected(() -> WorksRunner.preparePackagedServer(rig(profile).toFile(), runtime.toFile(), parsed(profile))) + .getMessage().contains("installer SHA-256 mismatch")); + } +} diff --git a/test-rig/unit-bootstrap/ForgeCapabilityBootstrap.java b/test-rig/unit-bootstrap/ForgeCapabilityBootstrap.java index 5e31da8f..facfff1a 100644 --- a/test-rig/unit-bootstrap/ForgeCapabilityBootstrap.java +++ b/test-rig/unit-bootstrap/ForgeCapabilityBootstrap.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.Optional; -/** Runs Forge's own token and event transforms for plain JUnit. */ +/** Runs Forge's capability-token transform and an empty loader for plain JUnit. */ public final class ForgeCapabilityBootstrap { private static final String MOD_LOADER = "net/minecraftforge/fml/ModLoader"; private static final String AGENT = "zone/moddev/mc/advantageworks/unit/ForgeCapabilityBootstrap"; @@ -17,18 +17,16 @@ public static void premain(String options, Instrumentation instrumentation) { @Override public byte[] transform(ClassLoader loader, String name, Class type, ProtectionDomain domain, byte[] bytes) { if (loader == null || name == null) return null; - boolean event = name.startsWith("net/minecraftforge/network/NetworkEvent") - || name.equals("net/minecraftforge/registries/MissingMappingsEvent"); boolean modLoader = MOD_LOADER.equals(name); String token = "net/minecraftforge/common/capabilities/CapabilityToken"; - if (!event && !modLoader && !(name.contains("$") || name.equals(token))) return null; - if (!event && !modLoader && !token.equals(name) + if (!modLoader && !(name.contains("$") || name.equals(token))) return null; + if (!modLoader && !token.equals(name) && loader.getResource("org/objectweb/asm/ClassReader.class") == null) return null; try { Class readerType = Class.forName("org.objectweb.asm.ClassReader", true, loader); Object reader = readerType.getConstructor(byte[].class).newInstance((Object)bytes); String parent = (String)readerType.getMethod("getSuperName").invoke(reader); - if (!event && !modLoader && !token.equals(name) && !token.equals(parent)) return null; + if (!modLoader && !token.equals(name) && !token.equals(parent)) return null; Class nodeType = Class.forName("org.objectweb.asm.tree.ClassNode", true, loader); Object node = nodeType.getConstructor().newInstance(); Class visitorType = Class.forName("org.objectweb.asm.ClassVisitor", true, loader); @@ -37,12 +35,6 @@ public static void premain(String options, Instrumentation instrumentation) { Object asmName = asmType.getMethod("getObjectType", String.class).invoke(null, name); if (modLoader) { addLoaderFixture(loader, nodeType, node, asmName); - } else if (event) { - Class transformer = Class.forName("net.minecraftforge.eventbus.EventSubclassTransformer", true, loader); - var transformed = (java.util.Optional) transformer.getMethod("transform", nodeType, asmType) - .invoke(transformer.getConstructor().newInstance(), node, asmName); - if (transformed.isEmpty()) return null; - node = transformed.get(); } else { Class phaseType = Class.forName( "cpw.mods.modlauncher.serviceapi.ILaunchPluginService$Phase", true, loader); @@ -90,7 +82,7 @@ private static void addLoaderFixture(ClassLoader loader, Class nodeType, Obje list.getMethod("insert", list).invoke(instructions, entry); return; } - throw new NoSuchMethodException("Forge 52 ModLoader()"); + throw new NoSuchMethodException("Forge 61 ModLoader()"); } public static void beforeModLoader(Class owner) throws ReflectiveOperationException { diff --git a/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/EclipseUnitLauncher.java b/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/EclipseUnitLauncher.java new file mode 100644 index 00000000..08d3c964 --- /dev/null +++ b/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/EclipseUnitLauncher.java @@ -0,0 +1,34 @@ +package zone.moddev.mc.advantageworks.unit; + +import java.util.ArrayList; +import java.util.List; +import org.junit.runner.JUnitCore; +import org.junit.runner.Result; +import org.junit.runner.notification.Failure; +import org.junit.runner.notification.RunListener; + +/** Independent compiler qualification must not mistake assumptions for successful tests. */ +public final class EclipseUnitLauncher { + private EclipseUnitLauncher() {} + + public static void main(String[] args) throws ClassNotFoundException { + int expected = Integer.parseInt(args[0]); + List> classes = new ArrayList<>(); + for (int index = 1; index < args.length; index++) classes.add(Class.forName(args[index])); + List assumptions = new ArrayList<>(); + JUnitCore core = new JUnitCore(); + core.addListener(new RunListener() { + @Override public void testAssumptionFailure(Failure failure) { assumptions.add(failure); } + }); + Result result = core.run(classes.toArray(Class[]::new)); + result.getFailures().forEach(failure -> System.err.println(failure.getTrace())); + assumptions.forEach(failure -> System.err.println("Unexpected assumption: " + failure)); + if (expected < 1 || result.getRunCount() != expected || !result.wasSuccessful() + || result.getIgnoreCount() != 0 || !assumptions.isEmpty()) { + throw new AssertionError("Incomplete Eclipse-output suite: " + result.getRunCount() + + " tests, " + result.getFailureCount() + " failures, " + result.getIgnoreCount() + + " ignored, " + assumptions.size() + " assumptions; expected " + expected); + } + System.out.println("PASS: " + expected + " tests; zero failures, ignored tests or assumptions"); + } +} diff --git a/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/Forge52UnitControl.java b/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/Forge61UnitControl.java similarity index 74% rename from test-rig/unit-support/zone/moddev/mc/advantageworks/unit/Forge52UnitControl.java rename to test-rig/unit-support/zone/moddev/mc/advantageworks/unit/Forge61UnitControl.java index edc01c8b..4448becd 100644 --- a/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/Forge52UnitControl.java +++ b/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/Forge61UnitControl.java @@ -11,15 +11,18 @@ import net.minecraftforge.registries.ForgeRegistries; /** Proves the test-only fixture reaches Forge's native item, registry, and capability paths. */ -public final class Forge52UnitControl { +public final class Forge61UnitControl { public static void main(String[] args) throws Exception { + net.minecraftforge.fml.ModLoader.get(); SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); - if (new ItemStack(Items.DIRT, 16).get(DataComponents.MAX_STACK_SIZE) != 64 - || !net.minecraftforge.fml.ModLoader.isLoadingStateValid() - || net.minecraftforge.fml.ModList.get().size() != 0) { - throw new AssertionError("Native item component gathering did not complete in the empty loader"); - } + if (new ItemStack(Items.DIRT, 16).get(DataComponents.MAX_STACK_SIZE) != 64) + throw new AssertionError("Native item component gathering did not complete"); + if (!net.minecraftforge.fml.ModLoader.isLoadingStateValid()) + throw new AssertionError("The native unit loader is invalid"); + if (net.minecraftforge.fml.ModList.get().size() != 0) + throw new AssertionError("The native unit loader contains mods: " + + net.minecraftforge.fml.ModList.get().size()); for (int run = 0; run < 2; run++) { ForgeUnitRegistries.registerVanillaFluidTypes(); ForgeUnitRegistries.registerForgeEntries("ATTRIBUTES", ForgeRegistries.Keys.ATTRIBUTES, ForgeRegistries.ATTRIBUTES); @@ -32,6 +35,6 @@ public static void main(String[] args) throws Exception { || (Object) ForgeCapabilities.FLUID_HANDLER == ForgeCapabilities.ITEM_HANDLER) { throw new AssertionError("Native Forge capability tokens were not transformed"); } - System.out.println("Forge 52 plain-JUnit bootstrap control PASS"); + System.out.println("Forge 61 plain-JUnit bootstrap control PASS"); } } diff --git a/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/ForgeUnitRegistries.java b/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/ForgeUnitRegistries.java index ba0a9063..49961780 100644 --- a/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/ForgeUnitRegistries.java +++ b/test-rig/unit-support/zone/moddev/mc/advantageworks/unit/ForgeUnitRegistries.java @@ -21,18 +21,30 @@ private ForgeUnitRegistries() {} public static synchronized void registerVanillaFluidTypes() throws ReflectiveOperationException { if (ForgeRegistries.FLUID_TYPES.get() == null) { + initializeRegistry(ForgeRegistries.Keys.FLUID_TYPES); + } + registerForgeEntries("VANILLA_FLUID_TYPES", ForgeRegistries.Keys.FLUID_TYPES, ForgeRegistries.FLUID_TYPES.get()); + } + + public static synchronized void registerIngredientSerializers() throws ReflectiveOperationException { + if (ForgeRegistries.INGREDIENT_SERIALIZERS.get() == null) { + initializeRegistry(ForgeRegistries.Keys.INGREDIENT_SERIALIZERS); + } + registerForgeEntries("INGREDIENT_SERIALIZERS", ForgeRegistries.Keys.INGREDIENT_SERIALIZERS, + ForgeRegistries.INGREDIENT_SERIALIZERS.get()); + } + + private static void initializeRegistry(ResourceKey key) throws ReflectiveOperationException { Field registries = ForgeRegistries.class.getDeclaredField("registries"); registries.setAccessible(true); DeferredRegister types = ((List) registries.get(null)).stream().map(DeferredRegister.class::cast) - .filter(entry -> ForgeRegistries.Keys.FLUID_TYPES.equals(entry.getRegistryKey())) - .findFirst().orElseThrow(() -> new IllegalStateException("Missing native fluid-type registry")); + .filter(entry -> key.equals(entry.getRegistryKey())) + .findFirst().orElseThrow(() -> new IllegalStateException("Missing native registry " + key)); NewRegistryEvent event = new NewRegistryEvent(); dispatch(types, "createRegistry", NewRegistryEvent.class, event); Method fill = NewRegistryEvent.class.getDeclaredMethod("fill"); fill.setAccessible(true); fill.invoke(event); - } - registerForgeEntries("VANILLA_FLUID_TYPES", ForgeRegistries.Keys.FLUID_TYPES, ForgeRegistries.FLUID_TYPES.get()); } public static synchronized void registerForgeEntries(String name, ResourceKey key, IForgeRegistry registry) diff --git a/tools/generate-item-definitions.js b/tools/generate-item-definitions.js new file mode 100644 index 00000000..4260b0be --- /dev/null +++ b/tools/generate-item-definitions.js @@ -0,0 +1,22 @@ +// Keep native item definitions beside the unchanged, shared block/item models. +const fs = require('fs'); +const path = require('path'); +const root = path.resolve(__dirname, '..', 'src/main/resources/assets/basepower'); +const source = path.join(root, 'models/item'); +const target = path.join(root, 'items'); +const check = process.argv.includes('--check'); +const names = fs.readdirSync(source).filter(name => name.endsWith('.json')).sort(); +if (!names.length) throw new Error('No Power item models found'); +if (!check) fs.mkdirSync(target, {recursive: true}); +for (const name of names) { + const text = JSON.stringify({model: {type: 'minecraft:model', model: 'basepower:item/' + name.slice(0, -5)}}, null, 2) + '\n'; + const file = path.join(target, name); + if (check) { + if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n') !== text) + throw new Error('Missing or stale native item definition: ' + file); + } else fs.writeFileSync(file, text); +} +for (const name of fs.readdirSync(target)) { + if (name.endsWith('.json') && !names.includes(name)) throw new Error('Orphan native item definition: ' + name); +} +console.log(names.length + ' Power native item definitions ' + (check ? 'verified' : 'generated')); diff --git a/tools/generate-recipe-unlocks.js b/tools/generate-recipe-unlocks.js index 40945374..b4ff1884 100644 --- a/tools/generate-recipe-unlocks.js +++ b/tools/generate-recipe-unlocks.js @@ -14,6 +14,8 @@ const ignored = new Set(['item:minecraft:stick', 'item:minecraft:string', function choices(value) { if (Array.isArray(value)) return value.flatMap(choices); + if (typeof value === 'string') return value.startsWith('#') + ? [{tag: value.slice(1)}] : [{item: value}]; if (value && value.tag) return [{tag: value.tag}]; if (value && value.item) return [{item: value.item}]; throw new Error('Unsupported recipe ingredient: ' + JSON.stringify(value)); diff --git a/tools/verification/Verify-NativeEclipse.ps1 b/tools/verification/Verify-NativeEclipse.ps1 index 88a9b513..a9853b47 100644 --- a/tools/verification/Verify-NativeEclipse.ps1 +++ b/tools/verification/Verify-NativeEclipse.ps1 @@ -10,7 +10,7 @@ $ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path $Evidence = [IO.Path]::GetFullPath($Evidence) if (Test-Path -LiteralPath $Evidence) { throw 'Refusing to replace Eclipse evidence' } if ((Get-Content -LiteralPath (Join-Path $JavaHome 'release') -Raw) -notmatch '(?m)^JAVA_VERSION="21(?:\.|\+|")') { - throw 'The Forge 52 Eclipse build requires Java 21' + throw 'The native Eclipse build requires Java 21' } $null = New-Item -ItemType Directory -Path $Evidence $java = Join-Path $JavaHome 'bin/java.exe' @@ -64,6 +64,39 @@ try { } $report.compilations[$scope].classes = $classes.Count } + $inventory = @() + $expected = 0 + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $ProjectRoot 'build/test-results/test') -Filter 'TEST-*.xml') { + [xml]$suite = Get-Content -LiteralPath $file.FullName -Raw + if ([int]$suite.testsuite.failures -or [int]$suite.testsuite.errors -or [int]$suite.testsuite.skipped) { + throw 'The Gradle inventory includes failed or skipped tests' + } + $inventory += [string]$suite.testsuite.name + $expected += [int]$suite.testsuite.tests + } + if (-not $expected) { throw 'Missing Gradle test inventory' } + $runtime = @($outputs.test, $outputs.main, (Join-Path $ProjectRoot 'build/resources/main'), + (Join-Path $ProjectRoot 'build/resources/test')) + $libraries + $runtimeClasspath = $runtime -join ';' + $bootstrap = Join-Path $ProjectRoot 'build/unit-bootstrap/forge-native-unit-bootstrap.jar' + if (-not (Test-Path -LiteralPath $bootstrap -PathType Leaf)) { throw 'Missing native unit bootstrap' } + $testArgs = @('-Xmx2G', ('"-javaagent:' + $bootstrap.Replace('\', '/') + '"'), + ('"-Dbasepower.nativePackClasspath=' + $runtimeClasspath.Replace('\', '/') + '"'), + '-cp', ('"' + $runtimeClasspath.Replace('\', '/') + '"'), + 'zone.moddev.mc.advantageworks.unit.EclipseUnitLauncher', [string]$expected) + $inventory + $testArgFile = Join-Path $Evidence 'junit.args' + [IO.File]::WriteAllLines($testArgFile, $testArgs, [Text.UTF8Encoding]::new($false)) + Push-Location $ProjectRoot + try { + & $java "@$testArgFile" *> (Join-Path $Evidence 'junit.log') + $code = $LASTEXITCODE + } finally { Pop-Location } + $report['junit'] = @{ exitCode=$code; expectedTests=$expected; suites=$inventory } + if ($code -ne 0) { throw 'Fresh Eclipse-output tests failed; inspect retained junit.log' } + $summary = Get-Content -LiteralPath (Join-Path $Evidence 'junit.log') -Raw + if (-not $summary.Contains("PASS: $expected tests; zero failures, ignored tests or assumptions")) { + throw 'Missing exact fresh-output test count' + } $report.status = 'PASS' } catch { $report.status = 'FAIL' @@ -72,4 +105,4 @@ try { } finally { $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $Evidence 'ecj-results.json') -Encoding utf8 } -Write-Output 'PASS: both Eclipse source scopes compile with the generated production and test classpaths' +Write-Output 'PASS: both Eclipse source scopes compile and the complete test inventory passes on fresh ECJ output'