diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4548a4..de14c12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,16 @@ -name: Power Advantage 1.14 CI +name: Power Advantage 1.15 CI on: push: - branches: [master-1.14.4, 'feature/**'] + branches: [master-1.15.2, 'feature/**'] pull_request: - branches: [master-1.14.4] + branches: [master-1.15.2] permissions: contents: read concurrency: - group: poweradvantage-1.14-${{ github.workflow }}-${{ github.ref }} + group: poweradvantage-1.15-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/OreSpawn - ref: 9c2488cba02d317e3f31e24441dcc58273d8d0e6 + ref: 5613baf876747ed071cb7ad7e2aff4e0ce4f0a03 path: .ci-dependencies/OreSpawn - name: Use canonical LF dependency inputs # Use the same source bytes on hosted and local dependency builds. @@ -34,8 +34,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/BaseMetals - ref: 713802f93f2d1f650e1593fa35c487ea43add4a5 + ref: ac1781e7cade15045ffe0135077087b4d1722603 path: .ci-dependencies/BaseMetals + - name: Install pinned Java 25 Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' - name: Install Java 8 toolchain uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: @@ -44,8 +49,15 @@ jobs: - name: Install Java 17 for Gradle uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: - distribution: microsoft - java-version: '17' + distribution: temurin + java-version: '17.0.1+12' + - name: Use pinned build toolchains + # These are Gradle project properties, not JVM system properties. + shell: bash + run: >- + echo "MMD_GRADLE_TOOLCHAINS=-Porg.gradle.java.installations.paths=$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64 + -Porg.gradle.java.installations.auto-detect=false + -Porg.gradle.java.installations.auto-download=false" >> "$GITHUB_ENV" - name: Set up Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - name: Build pinned development dependencies @@ -56,16 +68,16 @@ jobs: orespawn="$GITHUB_WORKSPACE/.ci-dependencies/OreSpawn" basemetals="$GITHUB_WORKSPACE/.ci-dependencies/BaseMetals" chmod +x "$orespawn/gradlew" "$basemetals/gradlew" - (cd "$orespawn" && ./gradlew jar --no-daemon --stacktrace) - (cd "$basemetals" && ./gradlew jar --no-daemon --stacktrace) - osjar="$orespawn/build/libs-dev/OreSpawn-4.1.1.114041-deobf.jar" - bmjar="$basemetals/build/libs-dev/BaseMetals-3.0.1.114041-deobf.jar" + (cd "$orespawn" && ./gradlew $MMD_GRADLE_TOOLCHAINS jar --no-daemon --stacktrace) + (cd "$basemetals" && ./gradlew $MMD_GRADLE_TOOLCHAINS jar --no-daemon --stacktrace) + osjar="$orespawn/build/libs-dev/OreSpawn-4.1.1.115021-deobf.jar" + bmjar="$basemetals/build/libs-dev/BaseMetals-3.0.2.115021-deobf.jar" test -f "$osjar" && test -f "$bmjar" echo "orespawn=$osjar" >> "$GITHUB_OUTPUT" echo "basemetals=$bmjar" >> "$GITHUB_OUTPUT" - name: Build and audit release artifacts run: >- - ./gradlew clean check build javadoc verifyReleaseDependencies verifyReleaseArtifacts + ./gradlew $MMD_GRADLE_TOOLCHAINS clean check build javadoc verifyReleaseDependencies verifyReleaseArtifacts writeReleaseChecksums verifyEclipseProductionClasspath -PbaseMetalsDeobfJar=${{ steps.dependencies.outputs.basemetals }} -PoreSpawnDeobfJar=${{ steps.dependencies.outputs.orespawn }} @@ -73,20 +85,20 @@ jobs: - name: Upload audited release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: PowerAdvantage-1.14.4-${{ github.sha }} + name: PowerAdvantage-1.15.2-${{ github.sha }} if-no-files-found: error retention-days: 30 path: | - build/libs/PowerAdvantage-2.4.5.114041.jar - build/libs/PowerAdvantage-2.4.5.114041-sources.jar - build/libs/PowerAdvantage-2.4.5.114041-javadoc.jar + build/libs/PowerAdvantage-2.4.6.115021.jar + build/libs/PowerAdvantage-2.4.6.115021-sources.jar + build/libs/PowerAdvantage-2.4.6.115021-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt - name: Upload diagnostics on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: PowerAdvantage-1.14.4-diagnostics-${{ github.sha }} + name: PowerAdvantage-1.15.2-diagnostics-${{ github.sha }} if-no-files-found: ignore retention-days: 14 path: | diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c8493f6..447e5ab 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [master-1.14.4, 'feature/**'] + branches: [master-1.15.2, 'feature/**'] pull_request: - branches: [master-1.14.4] + branches: [master-1.15.2] schedule: - cron: '47 7 * * 4' @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/OreSpawn - ref: 9c2488cba02d317e3f31e24441dcc58273d8d0e6 + ref: 5613baf876747ed071cb7ad7e2aff4e0ce4f0a03 path: .ci-dependencies/OreSpawn - name: Use canonical LF dependency inputs # Use the same source bytes on hosted and local dependency builds. @@ -34,8 +34,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: MinecraftModDevelopmentMods/BaseMetals - ref: 713802f93f2d1f650e1593fa35c487ea43add4a5 + ref: ac1781e7cade15045ffe0135077087b4d1722603 path: .ci-dependencies/BaseMetals + - name: Install pinned Java 25 Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' - name: Install Java 8 toolchain uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: @@ -44,8 +49,15 @@ jobs: - name: Install Java 17 for Gradle uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: - distribution: microsoft - java-version: '17' + distribution: temurin + java-version: '17.0.1+12' + - name: Use pinned build toolchains + # These are Gradle project properties, not JVM system properties. + shell: bash + run: >- + echo "MMD_GRADLE_TOOLCHAINS=-Porg.gradle.java.installations.paths=$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64 + -Porg.gradle.java.installations.auto-detect=false + -Porg.gradle.java.installations.auto-download=false" >> "$GITHUB_ENV" - name: Set up Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - name: Build pinned development dependencies @@ -56,10 +68,10 @@ jobs: orespawn="$GITHUB_WORKSPACE/.ci-dependencies/OreSpawn" basemetals="$GITHUB_WORKSPACE/.ci-dependencies/BaseMetals" chmod +x "$orespawn/gradlew" "$basemetals/gradlew" - (cd "$orespawn" && ./gradlew jar --no-daemon --stacktrace) - (cd "$basemetals" && ./gradlew jar --no-daemon --stacktrace) - echo "orespawn=$orespawn/build/libs-dev/OreSpawn-4.1.1.114041-deobf.jar" >> "$GITHUB_OUTPUT" - echo "basemetals=$basemetals/build/libs-dev/BaseMetals-3.0.1.114041-deobf.jar" >> "$GITHUB_OUTPUT" + (cd "$orespawn" && ./gradlew $MMD_GRADLE_TOOLCHAINS jar --no-daemon --stacktrace) + (cd "$basemetals" && ./gradlew $MMD_GRADLE_TOOLCHAINS jar --no-daemon --stacktrace) + echo "orespawn=$orespawn/build/libs-dev/OreSpawn-4.1.1.115021-deobf.jar" >> "$GITHUB_OUTPUT" + echo "basemetals=$basemetals/build/libs-dev/BaseMetals-3.0.2.115021-deobf.jar" >> "$GITHUB_OUTPUT" - name: Initialize CodeQL uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 with: @@ -67,7 +79,7 @@ jobs: - name: Compile production code run: | chmod +x ./gradlew - ./gradlew clean classes --rerun-tasks --no-build-cache --no-daemon --stacktrace \ + ./gradlew $MMD_GRADLE_TOOLCHAINS clean classes --rerun-tasks --no-build-cache --no-daemon --stacktrace \ -PbaseMetalsDeobfJar=${{ steps.dependencies.outputs.basemetals }} \ -PoreSpawnDeobfJar=${{ steps.dependencies.outputs.orespawn }} - name: Analyze diff --git a/.github/workflows/validate-gradle-build.yml b/.github/workflows/validate-gradle-build.yml index c471b09..7d47484 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.14.4 + - master-1.15.2 - 'feature/**' pull_request: branches: - - master-1.14.4 + - master-1.15.2 permissions: contents: read diff --git a/.gitignore b/.gitignore index 03f14c4..1566f49 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ /test-rig/.gradle/ /test-rig/.cache/ /test-rig/build/ +/test-rig/logs/ /test-rig/migration-probe/.gradle/ /test-rig/migration-probe/build/ /test-rig/migration/.venv/ diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 59e968a..371d5d8 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,16 @@ +Power Advantage 2.4.6.115021 (Minecraft 1.15.2) + +- Move to Forge 31.2.57, snapshot mappings 20200514-1.15.1 and MCP 20200515.085601; retain ForgeGradle 7, Gradle 9.6.1 and Java 8 production bytecode. +- Use native interaction results and block properties, keeping solar panels solid and thin machinery non-solid. +- Allow conduit subclasses to supply native block properties without duplicating connection and lifecycle code. +- Use the native texture-atlas getters for GUI fluids and oil sprites; register frame cutout and oil translucent render layers during client setup. +- Update the still's light-query signature and retarget the shared legacy migration hook to 1.15 without renaming saved identities. +- Register recipe conditions on Forge's loading thread so parallel mod loading cannot intermittently remove recipes. +- Initialize the shared creative tab once when the three mods load together. +- Add 1.15-specific operating, migration, mining, recipe and client checks; the exact tested jars and remaining manual work are recorded in docs/1.15/CURRENT-QUALIFICATION.md. +- Record the accepted in-game visuals and recipe unlocks alongside the automated machine checks. +- Back up the entire world before upgrading and test only disposable copies. Earlier 1.14 acceptance does not qualify 1.15 upgrades. + Power Advantage 2.4.5.114041 (Minecraft 1.14.4) - Move to Minecraft 1.14.4 and Forge 28.2.26, retaining existing registry names, saved-state keys and public API packages. diff --git a/README.md b/README.md index 71f188a..3cd8448 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,54 @@ # PowerAdvantage -**Minecraft 1.14.4** +**Minecraft 1.15.2** -This port has passed the automated machine/migration tests and a manual check of -visuals and recipe unlocks. See the [qualification report](docs/1.14/BROADER-QUALIFICATION.md) -for coverage and limits. Use disposable copies for upgrades. +Power Advantage 2.4.6.115021 targets Minecraft 1.15.2. See the +[current test record](docs/1.15/CURRENT-QUALIFICATION.md) for the exact jars, +automated results and remaining limits. Visuals and recipe unlocks were +accepted on 27 October 2026; machine behavior is covered by the recorded +automated tests. Power Advantage provides shared power, fluids, item transport and compatibility APIs for Steam Advantage and Electric Advantage. -This port keeps existing registry names and carries forward migration from 1.10 and 1.12. -Always back up a world before upgrading, and check that its other mods also support the target version. -The archived [1.13 qualification notes](docs/1.13/CURRENT-QUALIFICATION.md) separate its tested scenarios from remaining gaps; -they are not a guarantee that an entire modpack can be upgraded without losses. -The [1.14 qualification report](docs/1.14/BROADER-QUALIFICATION.md) records the -repeated checks, exact tested jars and remaining limits for this candidate. +**Back up the entire world before upgrading. Test only disposable copies.** +The port retains existing registry names and legacy migration code. Upgrade tests +use disposable original and maintained saves, including connected machines. +Check every other mod's target-version support too. +The accepted [1.14 results](docs/1.14/BROADER-QUALIFICATION.md) are historical +evidence, not qualification of this port or a whole modpack. Recipe-book discovery now follows relevant ingredients. See the [all-Advantage material checklist and automated discovery tests](docs/1.13/RECIPE-DISCOVERY.md). To regenerate the checked-in unlock JSON after changing recipes, run `node tools/generate-recipe-unlocks.js`; `check` rejects missing or stale ingredient criteria. 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.14.4 +## Building Minecraft 1.15.2 -The `master-1.14.4` build uses Forge 28.2.26, ForgeGradle 7.0.34 and Gradle 9.6.1. Run Gradle with Java 17; Gradle resolves the Java 8 production toolchain. +The `master-1.15.2` build uses Forge 31.2.57, snapshot mappings +`20200514-1.15.1`, MCP `20200515.085601`, ForgeGradle 7.0.34 and Gradle 9.6.1. +Run Gradle with Java 17 and compile production code with Java 8. Mavenizer also +needs Java 25. CI pins all three JDKs; the [test commands](docs/1.15/TEST-HARNESS.md) +show how to select local installations explicitly. -Build the pinned Base Metals `3.0.1.114041` and OreSpawn `4.1.1.114041` deobf jars in their sibling checkouts, then run: +Build the pinned Base Metals `3.0.2.115021` and OreSpawn `4.1.1.115021` deobf jars first. +The exact Base Metals source is +`ac1781e7cade15045ffe0135077087b4d1722603`. CI builds that dependency first; +the checksums and complete source pins are in `gradle.properties`. + +Then run from `D:\PowerAdvantage\PA115\PowerAdvantage`: ```text gradlew.bat clean check build javadoc verifyReleaseDependencies verifyReleaseArtifacts writeReleaseChecksums verifyEclipseProductionClasspath ``` -The default dependency paths follow the local MMD checkout layout. Other layouts can pass `-PbaseMetalsDeobfJar=` and `-PoreSpawnDeobfJar=`. Dependencies are present on development classpaths but are never embedded in PowerAdvantage artifacts. +The default dependency jars are: + +```text +D:\BaseMetals\BaseMetals 115\BaseMetals\build\libs-dev\BaseMetals-3.0.2.115021-deobf.jar +D:\MinecraftMineralogy\OS115\OreSpawn\build\libs-dev\OreSpawn-4.1.1.115021-deobf.jar +``` + +Other layouts can pass `-PbaseMetalsDeobfJar=` and `-PoreSpawnDeobfJar=`. +Dependencies are never embedded in PowerAdvantage artifacts. Dependency checksums and source commits are recorded in `gradle.properties`. Unknown artifacts fail the dependency audit. Archives use reproducible ordering, @@ -47,39 +66,30 @@ 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 test all three Advantage mods together, run `gradlew.bat prepareCombinedEclipseLaunch`, refresh Eclipse, and launch `runClientAllAdvantage.launch`. This additional profile loads the pinned Steam/Electric deobf jars, Base Metals and OreSpawn alongside Power's Eclipse output. It uses the same `run` directory and test saves; it does not copy, open or modify a save automatically. The ordinary `runClient.launch` remains Power-only. Alternative checkout layouts can pass `-PsteamAdvantageDeobfJar=` and `-PelectricAdvantageDeobfJar=`; supplied jars must match the `test-rig/profiles/native114/dev-core.json` pins. Regenerate the combined launch after refreshing its dependencies or the base launch. - -The combined [client resource probe](test-rig/client-resources/README.md) checks native baked models, textures, creative entries, translated item names, fluid rendering and tank gauges without opening a save. Static resource and locale regressions also run under `check`. Automated recipe-book fixtures and the accepted manual visual/recipe-unlock checks have separate scopes; see the [current port status](docs/1.14/PORT-STATUS.md). +To prepare a combined client, build the Steam/Electric `2.2.7.115021` deobf jars, +run `gradlew.bat prepareCombinedEclipseLaunch`, refresh Eclipse, then launch +`runClientAllAdvantage.launch`. Inputs must match +`test-rig/profiles/native115/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. -The [broader 1.14 test results and play-test checklist](docs/1.14/BROADER-QUALIFICATION.md) separate migration, connected machinery, endurance and manual checks. Test worlds and reports stay local; use disposable copies when trying an old save. +Client-resource and recipe-book checks run against the 1.15 candidate. Manual +visual and interaction acceptance remains separate; see the current test record. ## Compatibility -The implementation namespace is `zone.moddev.mc.poweradvantage`. The historical `cyano.poweradvantage.api` surface, including `FluidNetworkApi`, remains available to dependent mods. Registry names, fluids, tile IDs, NBT keys, OreSpawn provider IDs, and configuration meaning remain stable. - -Forge 1.14 handles oil rendering, fluid containers, registered menus, tag updates -and parts-loot events directly. Advantage's public fluid contract is bridged to -native sided fluid capabilities; its millibucket units and saved tank keys remain -unchanged. The remaining coremod runs before vanilla's legacy-world data fixes. - -PowerAdvantage owns the shared pre-flattening migration hook used by all three Advantage mods. See [the 1.13 migration guide](docs/1.13/MIGRATION.md) before opening an irreplaceable 1.10 or 1.12 world. The [Advantage Works rig](test-rig/README.md) provides repeatable machine, restart, world-generation, and migration checks. - -The [endurance protocol](docs/1.13/ENDURANCE.md) exercises repeat-batch operation in fresh and migrated disposable worlds, followed by a complete server-process restart. Its coverage limits are separate from frozen-state migration and manual client acceptance. - -The [chunk and moving-drill protocol](docs/1.13/CHUNK-AND-DRILLS.md) adds genuine chunk unload/reload witnesses, finite-energy track moves in all six facings, rear-output conservation, and mid-block progress checks. - -The [partial-network and extended-drill protocol](docs/1.13/PARTIAL-NETWORKS-AND-EXTENDED-DRILLS.md) separates endpoint unloading/recovery from whole-network reloads, and covers repeated moves, vertical track travel, junctions, blocked output and redstone interruption. - -The archived [player and entity migration report](docs/1.13/PLAYER-AND-ENTITY-MIGRATION.md) covers player files, inventories, nested items, ownership, saved entities and full restarts on 1.13. Its eight routes passed with the repaired 1.12 bridge. Use Electric Advantage `2.2.3.112021` for that bridge's turbine and drill fixes; the original `2.2.1.112021` jar remains a failing historical control. +The implementation namespace remains `zone.moddev.mc.poweradvantage`, with the +historical `cyano.poweradvantage.api` package retained. The port keeps registry, +fluid, tile, NBT and provider identities. The current test record distinguishes +machine preservation, actual operation and vanilla migration differences. -The [additional-dimension protocol](docs/1.13/DIMENSIONS-AND-ENTITY-LIFECYCLE.md) -covers Nether/End player and entity fixtures, native passenger/leash relationships -and separate death/respawn controls. The [transformer-order report](docs/1.13/TRANSFORMER-ORDERING.md) -qualifies both BaseMetals/Advantage application orders and records the -order-dependent provenance of loose-file backups. +The 1.15 changes use native activation results, non-solid block properties, +texture-atlas access and client render-layer registration. The shared migration +hook is retargeted to 1.15; its presence is not proof that an upgrade is safe. -Networks use loaded chunks only. Missing conduit sections interrupt distribution; -normal chunk loading reconnects them without replacing pipes. The same protocol -records acquisition-stack diagnostics and pipe-only bridge regressions. +The [legacy migration guide](docs/1.13/MIGRATION.md) and +[Advantage Works rig](test-rig/README.md) describe the earlier protocols. +The [1.15 status](docs/1.15/PORT-STATUS.md) links the current qualification. 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 13acf67..4988b28 100644 --- a/build.gradle +++ b/build.gradle @@ -94,7 +94,7 @@ minecraft { def forgeRunModClassesDirectory = layout.buildDirectory.dir('forge-run-mod-classes/main') def forgeMainOutputPath = forgeRunModClassesDirectory.get().asFile.absolutePath def mainModClasses = "${mod_id}%%${forgeMainOutputPath}${File.pathSeparator}${mod_id}%%${forgeMainOutputPath}" - def forge28RunTargets = [client: 'fmluserdevclient', server: 'fmluserdevserver', data: 'fmluserdevdata'] + def forge31RunTargets = [client: 'fmluserdevclient', server: 'fmluserdevserver', data: 'fmluserdevdata'] runs { configureEach { mainClass = 'net.minecraftforge.userdev.LaunchTesting' @@ -102,7 +102,7 @@ minecraft { systemProperty 'forge.logging.markers', 'REGISTRIES' systemProperty 'forge.logging.console.level', 'info' mods { poweradvantage { source sourceSets.main } } - environment 'target', forge28RunTargets[name] + environment 'target', forge31RunTargets[name] environment 'MCP_MAPPINGS', "${mapping_channel}_${mapping_version}" environment 'MCP_VERSION', mcp_version environment 'FORGE_VERSION', forge_version @@ -130,7 +130,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.14 launches.' + description = 'Merges classes and processed resources for Forge 1.15 launches.' dependsOn tasks.named('classes') from sourceSets.main.output into forgeRunModClassesDirectory @@ -165,11 +165,11 @@ repositories { // The default paths mirror the local MMD checkout layout without tying the // build to a drive letter. CI and other layouts can override either path. def baseMetalsDeobfJar = file(providers.gradleProperty('baseMetalsDeobfJar') - .getOrElse('../../../BaseMetals/BaseMetals 114/BaseMetals/build/libs-dev/' - + 'BaseMetals-3.0.1.114041-deobf.jar')) + .getOrElse('../../../BaseMetals/BaseMetals 115/BaseMetals/build/libs-dev/' + + 'BaseMetals-3.0.2.115021-deobf.jar')) def oreSpawnDeobfJar = file(providers.gradleProperty('oreSpawnDeobfJar') - .getOrElse('../../../MinecraftMineralogy/OS114/OreSpawn/build/libs-dev/' - + 'OreSpawn-4.1.1.114041-deobf.jar')) + .getOrElse('../../../MinecraftMineralogy/OS115/OreSpawn/build/libs-dev/' + + 'OreSpawn-4.1.1.115021-deobf.jar')) dependencies { implementation minecraft.dependency( @@ -314,14 +314,17 @@ tasks.register('verifyReleaseConfiguration') { group = 'verification' description = 'Validates release identity, target metadata, and optional integration policy.' doLast { - if (project.mod_version != '2.4.5.114041') { + if (new JsonSlurper().parse(file('src/main/resources/pack.mcmeta')).pack.pack_format != 5) { + throw new GradleException('Minecraft 1.15.2 resources must use pack format 5') + } + if (project.mod_version != '2.4.6.115021') { throw new GradleException("Unexpected PowerAdvantage release version: ${project.mod_version}") } - if (project.minecraft_version != '1.14.4' - || project.forge_version != '28.2.26' + if (project.minecraft_version != '1.15.2' + || project.forge_version != '31.2.57' || project.mapping_channel != 'snapshot' - || project.mapping_version != '20190719-1.14.3' - || project.mcp_version != '20190829.143755') { + || project.mapping_version != '20200514-1.15.1' + || project.mcp_version != '20200515.085601') { throw new GradleException('Unexpected Minecraft, Forge, or mappings target') } if (project.loader_name != 'forge' || project.loader_code != '1' @@ -338,19 +341,19 @@ tasks.register('verifyReleaseConfiguration') { } if (project.cf_requirements != 'base-metals,mmd-orespawn' || project.cf_optionals != 'additional-loot-tables' - || project.cf_versions != '1.14.4') { + || project.cf_versions != '1.15.2') { throw new GradleException('Unexpected CurseForge dependency or game-version metadata') } - if (project.basemetals_version != '3.0.1.114041' - || project.orespawn_version != '4.1.1.114041' - || project.basemetals_source_commit != '713802f93f2d1f650e1593fa35c487ea43add4a5' - || project.orespawn_source_commit != '9c2488cba02d317e3f31e24441dcc58273d8d0e6') { + if (project.basemetals_version != '3.0.2.115021' + || project.orespawn_version != '4.1.1.115021' + || project.basemetals_source_commit != 'ac1781e7cade15045ffe0135077087b4d1722603' + || project.orespawn_source_commit != '5613baf876747ed071cb7ad7e2aff4e0ce4f0a03') { throw new GradleException('Unexpected Base Metals or OreSpawn dependency identity') } String mainSource = file( 'src/main/java/zone/moddev/mc/poweradvantage/PowerAdvantage.java').getText('UTF-8') if (!mainSource.contains('MODID = "poweradvantage"') - || !mainSource.contains('VERSION = "2.4.5.114041"') + || !mainSource.contains('VERSION = "2.4.6.115021"') || !mainSource.contains('@Mod(PowerAdvantage.MODID)')) { throw new GradleException('PowerAdvantage source identity changed') } @@ -1132,9 +1135,9 @@ tasks.register('clientResourceProbeJar', Jar) { // these deobf jars are runtime inputs, never release contents or compile dependencies. def advantageExtensionJars = [ steamadvantage: file(providers.gradleProperty('steamAdvantageDeobfJar').getOrElse( - '../SteamAdvantage/SteamAdvantage/build/libs-dev/SteamAdvantage-2.2.6.114041-deobf.jar')), + '../SteamAdvantage/SteamAdvantage/build/libs-dev/SteamAdvantage-2.2.7.115021-deobf.jar')), electricadvantage: file(providers.gradleProperty('electricAdvantageDeobfJar').getOrElse( - '../ElectricAdvantage/ElectricAdvantage/build/libs-dev/ElectricAdvantage-2.2.6.114041-deobf.jar')) + '../ElectricAdvantage/ElectricAdvantage/build/libs-dev/ElectricAdvantage-2.2.7.115021-deobf.jar')) ] tasks.register('prepareCombinedEclipseLaunch') { @@ -1142,7 +1145,7 @@ tasks.register('prepareCombinedEclipseLaunch') { description = 'Creates an additional production-only Eclipse client launch with all three Advantage mods.' dependsOn tasks.named('verifyEclipseProductionClasspath') doLast { - def profile = new JsonSlurper().parse(file('test-rig/profiles/native114/dev-core.json')) + def profile = new JsonSlurper().parse(file('test-rig/profiles/native115/dev-core.json')) advantageExtensionJars.each { String id, File jar -> def pin = profile.mods.find { it.id == id } if (!jar.isFile()) throw new GradleException("Build ${id} first, or override its *AdvantageDeobfJar property: ${jar}") diff --git a/docs/1.15/CLIENT-RESOURCES.md b/docs/1.15/CLIENT-RESOURCES.md new file mode 100644 index 0000000..9fd1970 --- /dev/null +++ b/docs/1.15/CLIENT-RESOURCES.md @@ -0,0 +1,482 @@ +# Native 1.15 Client Resources + +## Status + +Candidate F passed the actual combined graphical probe on 2026-10-26, +using fresh F server recipe packets for all three modes. The primary resource, +model, tank, fluid and recipe checks passed, along with all 25 available +worldless renderer geometry cases. The existing captures and pixel thresholds +are unchanged. The maintainer accepted in-game visuals and recipe unlocks on +27 October 2026. That approval does not individually qualify every remaining +interactive case, including hydroturbine orientation/spin or real menu sync. + +Fresh Eclipse Compiler for Java (ECJ) compilation of production and test sources +passed for all three projects. Tests executed from those separate ECJ outputs +also passed: Power 66, Steam 68 and Electric 45. These are separate gates from +the graphical results below. The accepted 1.14 behavior remains the reference, +not a substitute for native 1.15 evidence. + +Candidate F uses Base Metals `3.0.2.115021`. Its full native recipe campaign, +combined graphical probe and fresh ECJ checks passed. The earlier no-fixture F +run and complete D evidence remain retained separately; neither substitutes for +the fresh F recipe exports used by the full run. + +| Target | Required Value | +| --- | --- | +| Minecraft / Forge | `1.15.2` / `31.2.57` | +| Mappings / MCP | `snapshot_20200514-1.15.1` / `20200515.085601` | +| Power | `2.4.6.115021` | +| Steam / Electric | each `2.2.7.115021` | +| Runtime / build | Java 8 client; repository Java 17 Gradle process and Java 8 compilation toolchain | +| Probe resource pack | format `5` | + +## Candidate F + +The F packaged manifest is +`test-rig/build/native115-inputs-20261026-final-f-archived.json`, SHA-256 +`A9BCD5A442F6332010F9D467BD00A685AA953339C39545812B32B825B96E64AA`. +Base Metals `3.0.2.115021` is pinned to source +`ac1781e7cade15045ffe0135077087b4d1722603`, packaged SHA-256 +`3F50AFBEF17800ECADE42A69B81E541E9FA8C6C4C392ED4DD0FE5A66DC9EFF07` +and development SHA-256 +`426FE639C97E1DE4F6624957E3CBDE68A5FB7D415A1047903A29DCB35FD9ADA2`. + +The dependency-only Advantage commits are: + +| Project | Local Source | Full Release-Gate Tests | +| --- | --- | --- | +| Power | `6666099a734eba16561d98c86f3f4bb1b7c742f7` | 66 PASS | +| Steam | `07f6a0503c57df0b51f7ac4370c0ff5eda0232f5` | 68 PASS | +| Electric | `58bb1a78d7a218779cf92a866db575f72a577546` | 45 PASS | + +Each project completed one private-cache full release/dependency/artifact, +Javadoc and Eclipse verification run, without `clean` or a server/client launch. +All twelve Advantage release/development/source/Javadoc jars are byte-identical +to their previous versions. The full hashes, comparisons, logs and retained test +XML are under `build/base115-pin-propagation-20261026/`; its +`release-results.json` records the combined result. Base and Power dependency +commits are local-only and unpublished. Publish the exact dependency sources +upstream before PR/hosted builds; local success does not establish hosted +reproducibility or publication readiness. + +`clientResourceProbeJar verifyEclipseProductionClasspath prepareCombinedEclipseLaunch` +then passed using only the private Gradle cache. The log is +`build/client-resource-compile-runs/final-f-prepare-20261026-143241.log`. +The unchanged probe and hash-addressed Steam/Electric jars were retained. +All 38 native launcher, 95 primary-preservation and 40 geometry wiring checks +passed again without changing thresholds or masks. + +| F Evidence | Location | +| --- | --- | +| Preflight, no Java started | `build/client-resource-runs/20261026-143623-7ee23754` | +| Actual resource/geometry run, no recipe fixtures | `build/client-resource-runs/20261026-143712-f1377150` | +| Earlier no-fixture input verification | `build/client-resource-compile-runs/final-f-base-client-provenance.json` | +| Full-fixture preflight, no Java started | `build/client-resource-runs/20261026-145925-0c2bf4e9` | +| Actual full recipe/resource/geometry run | `build/client-resource-runs/20261026-150022-5694d3ab` | +| Full runtime/input verification | `build/client-resource-compile-runs/final-f-full-client-provenance.json` | +| Fresh native recipe campaign | `test-rig/build/native115-recipes-20261026-f/native115-recipes-results.json` | +| Final recipe fixture audit | `build/client-resource-compile-runs/final-f-recipe-fixtures-final-audit.json` | +| Fresh ECJ main/test compilation and JUnit | `build/ecj-final-f/20261026-143452` | + +Both F graphical runs passed 94 item models/names, 905 block states, 87 creative +entries, both tank-floor faces, tank simulation/10,000 mB updates, actual gauge +pixel `1E386E`, all six fluid cases and all 25 worldless renderer geometry cases. +All 537 selected Power production files matched its development jar exactly. +The full report SHA-256 is +`18BE5F772EDBCDB17A1F33E68C7B2DB2F025D2EBEEAD625BA02695A84C1E87C8`. +The gauge, recipe image and geometry report are byte-identical to D's retained +outputs, despite using newly generated F packets and the F registry snapshot. + +The fresh F native campaign completed five unlock boots and 18 progression +checks across all three modes, both rubber-alias settings, reload and restart. +All ten unlock operations passed 109 discovery cases and 59 potato-crafting +checks each, with zero skips; the installed seed fingerprint was unchanged. +The campaign report SHA-256 is +`F18F848945B26A9633775855108B6E8E103D749D14A6F97B768FB9717F64CD59`. +Before graphical use, all six sweep packet/report hashes and the registry +snapshot were checked against this campaign's fixture manifest. The native saved +mod versions and six packaged candidates matched F. The registry snapshot has +SHA-256 `36A2357A5EB172689D6B4525D89DFD276C3FDF541A0F0E053A516EE9BC720708`. +No D packet or registry snapshot was reused. + +| F Recipe Mode | Recipes | Native Cells | Result | +| --- | --- | --- | --- | +| Normal | 93 | 77 | PASS | +| Tech | 87 | 77 | PASS | +| Apocalyptic | 82 | 73 | PASS | + +The full run reports `recipe_book_grouping_status=PASS`. Its actual thirteen-cell +image contains 8,585 qualifying pixels against the unchanged `> 1,000` assertion +in the original `1000 x 180` capture. Both recipe and tank images were visually +inspected. Their SHA-256 values are +`C0868043B7C356BBFF80825662562F0AA2C0BB5C822DD36DEC2B1D71BFE3100D` +and `7D3A9295A75EDC67442A06B300A3077E37EE4335AAA94D48F65E22E14CE5A1A6`. +The earlier F `NOT_RUN_NO_NATIVE_FIXTURES` result remains explicitly partial, +not relabelled as the full pass. + +The hydroturbine remains `MANUAL_GAP_WORLD_REQUIRED`; all normal gameplay/manual +acceptance limits still apply. The graphical probe opened no integrated server +or existing save. All 216 files in the D manual runtime and 175 retained D recipe +inputs were fingerprinted unchanged. The separate F manual copy and its source +also remain unchanged; only `-CheckOnly` was run on that copy. The recipe server +was stopped after the campaign, with no listener remaining on port 25577. + +Fresh ECJ compilation used the actual Base 3.0.2 development jar for all three +projects, separate main/test output trees and Java 8 bytecode. All six compiles +had zero errors; main/test warning counts remain Power 97/1, Steam 40/0 and +Electric 52/0. Java 8 JUnit execution from those fresh ECJ outputs also passed +66/68/45 tests. `ecj-results.json` and `ecj-junit-results.json` retain source refs, +source/dependency hashes, commands and results. These are compile/test checks, +not an interactive Eclipse client launch. + +The separate private-cache harness gate also passed all 95 tests and +`assemble verifyHarnessIsolation verifyWorksNative115Inputs` against the current +F profile, without `clean` or a Minecraft launch. Its report is +`build/client-resource-compile-runs/final-f-harness-online-20261026-144952/harness-results.json`. +Harness E remains SHA-256 +`C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B` +and its runner remains +`21DF61B5501D3E5E0EB10394773C5D86C5AFCEB478AE09426AA8B980F267C361`. +The first offline attempt stopped before compilation because Mixin 0.8.2 was +not cached; that failure log is retained separately. + +The disposable F manual copy is prepared, not launched, from the stopped passed +F `packaged-current` runtime. Its 29 restart guards and final `-CheckOnly` passed; +the complete source and D manual fingerprints are unchanged. See +[Manual Testing](MANUAL-TESTING.md) for its path, checksums and launch limits. + +## Retained D Inputs + +The immutable packaged target manifest is +`test-rig/build/native115-inputs-20261026-final-d-archived.json`, SHA-256 +`3605B9BBF2B5C94C8278B31B5984969DD55B8F277E3B994496F045FA7CA79BE8`. +The native server exported the recipe fixtures with these packaged candidates: + +| Candidate | Production Source | Packaged SHA-256 | +| --- | --- | --- | +| Power | `75d24d2641e8123ff7a7f961d9a1e5a1d5203194` | `F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C` | +| Steam | `7883d9b697a7c0961c721f01baa6ed1046d256de` | `564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2` | +| Electric | `f39a7f8f2f09727cd8dec1a1ebc553d52d710404` | `C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E` | + +The client uses the mapped development inputs, not packaged production jars: + +| Input | Development SHA-256 | +| --- | --- | +| Power | `F5029356CB837E7D782BA59D4C3B961CE0AAA116B739F14DA63F0A13562A2D1F` | +| Steam | `CB02BE3E9CCD1E4498000503D825B2B6FD350EBDF37A6414EE23697A22BE925E` | +| Electric | `7CF50EA6FBEB30F1683359EFF5C541B6674A30D12AA4C7D0B236CE5E52D9D7DF` | +| BaseMetals | `A98EAC370D908F14103DB6D064C738160161FDD7203F30905562B23EEE265210` | +| OreSpawn | `358B13375A4B60622C491D6729290971A446FF14D524A600CA13710934DB3FDC` | + +Steam and Electric were loaded from their hash-addressed D archives. Power +was loaded from a snapshot of Gradle's production output: all 537 selected +class/resource files matched its D development jar byte-for-byte. The launcher +records that jar as provenance, not as a second installed Power mod. The server +fixture used harness C, packaged SHA-256 +`B8F733F37B51A2EE8A0C11F21C1C84336FD8FCCDE998611DA334021C29CC34FA`; +the harness is not installed in the client. + +## Native API Adaptation + +The installed mapped Forge source archive under +`.gradle/mavenizer/repo/net/minecraftforge/forge/1.15.2-31.2.57/` +was inspected directly. Its `forge-1.15.2-31.2.57-sources-snapshot-20200514-1.15.1.jar` +SHA-256 is `937F0B7D7098D4D1A12EA306108122B1E443CA638D120D9040FF2B577E9A0314`. +The API names below use this snapshot. Official-mapping names, including those +in IronAgeFurniture's 1.15 client probe, are not interchangeable with them. + +- Fluid rendering uses `ILightReader` and the native dispatcher with a real + `BufferBuilder`/`IVertexBuilder`. Its tiny read-only neighbourhood provides + full light and plains biome colors without a world or light-manager instance. +- `finishDrawing()` resets the builder's vertex count in 1.15. Read the completed + `DrawState` and byte slice from `getNextBuffer()` instead. UV0/color offsets + come from the actual `VertexFormatElement` usage/index and Forge `getOffset()`. +- Sprite lookup uses the block atlas's `getAtlasSpriteGetter()` function. +- Baked face sprites use the snapshot's `BakedQuad.func_187508_a()` accessor. + The missing-face-texture check is unchanged. +- Tank and recipe framebuffer state/readback use `RenderSystem`. The native + container/screen, recipe manager, recipe lists and actual recipe widgets remain. +- Launch guards require the exact game, Forge, snapshot and MCP target, reject + 1.13/1.14 archives and unsafe world/server arguments, and require the stated + Power/Steam/Electric versions. Probe metadata also requires the exact target. + +The probe uses the production render layers, fluid attributes, tank shapes, +GUI atlas/UV handling and blit offsets. It does not replace those renderers. + +## Retained Assertions + +- Every registered item and valid block state in all three Advantage namespaces: + English item translations, baked geometry, missing model/particle/face sprites, + shared creative tab entries/icon, and modern/legacy still-name agreement. + Native fluid block models remain intentionally particle-only. +- Both portable and metal tanks must leave Minecraft's actual grass-floor top + face visible beneath their inset corners. +- Native Forge tank fill simulation must leave the tank empty. Executed fill + and drain remain 10,000 mB. Full and empty production update packets must + reproduce the corresponding client tank contents. +- The actual filtered-tank GUI uses its registered native container type and + initialized screen. Production decorations draw water and frame into the + original `128 x 96` framebuffer. The image is written before checking pixel + `(88,39)`: `blue > red + 20` and `blue > green + 10`, unchanged. +- Crude/refined buckets retain native bucket, filled-bucket and capability + identities with 1,000 mB contents. Still and flowing crude/refined oil retain + their four exact `poweradvantage:blocks/*` sprites and `FFFFFF` top-face tint. + Native water/lava remain controls. Top-face output requires at least four + vertices; UV bounds are unchanged. Temporary water/lava tags are restored. +- New native server packets for Normal, Tech and Apocalyptic retain complete + packet consumption, successful manifest, unique recipe IDs, output/count, + group/type, recipe count, one-output-per-cell and accessible-variant checks. + Grouping must not split same-output alternatives or merge different outputs. +- The same four electronics plus conveyor/eight filters remain exactly thirteen + distinct native recipe cells. Actual widgets and translated labels render to + the same `1000 x 180` framebuffer, with the same background, spacing, projection + and color test. `recipe-book-cells.png` is retained before requiring more than + 1,000 colored pixels. No tolerance or capture-coordinate changes were made. + +## Static Verification + +From the Power 1.15 checkout, without Gradle, Java or graphical execution: + +```powershell +./test-rig/client-resources/test-launcher.ps1 -StaticOnly +./test-rig/client-resources/test-probe-contract.ps1 +``` + +Results: 33 launcher checks and 95 preservation checks passed. By default, the +preservation test finds `PA114/PowerAdvantage/test-rig/client-resources` beside +the `PA115` directory, relative to the script rather than the current directory. +For another workspace layout, pass the accepted source directory explicitly: + +```powershell +./test-rig/client-resources/test-probe-contract.ps1 -ReferenceRoot '' +``` + +Each reference file must match its pinned SHA-256 before comparison. A different +path does not permit a different baseline. The pins cover the original file +bytes, before line-ending or native API/version normalization: + +| Reference File | SHA-256 | +| --- | --- | +| `ClientResourceProbe.java` | `A6003A2EE76006C3BA3E279EC49E49DA69FD0A9B9146C7D0328288A85E173E02` | +| `TankVisualProbe.java` | `1256340A70F53F5B0D2C9B824DF5C9C6E2F0DFFAD1AFDB4EA560FE03B30137F4` | +| `OilRenderProbe.java` | `DF697A6BE69453F4956C25756110F2BB51B2DE78D95F4BF79A7612B49F18B1BA` | +| `RecipeBookGroupingProbe.java` | `9D842426742BEC9B6E0BEB1C763CAF1CF0D9C96F006A53796459B4B92BA1F4F5` | + +The checks compare every original assertion and resource/fixture diagnostic literal, plus the +complete tank and recipe implementations after only native API/version +normalization. Bucket checks, all six fluid cases, tag restoration and separate +failure evidence are checked too. These are static regression checks, not a +substitute for Java compilation or actual image results. + +## Verified Runs + +`clientResourceProbeJar verifyEclipseProductionClasspath prepareCombinedEclipseLaunch` +passed for D. Steam and Electric also passed their own +`verifyEclipseProductionClasspath` tasks. All three native Eclipse targets were +regenerated without cleaning or replacing existing output classes. The probe's +nine class files use Java 8 bytecode (major version 52), including the +renderer-geometry probe. The native baked-quad accessor fix remains as described +above. + +The build logs are under `build/client-resource-compile-runs/`: +`final-d-prepare-20261026-130532.log`, +`final-d-steam-eclipse-20261026-130711.log` and +`final-d-electric-eclipse-20261026-130728.log`. Earlier successful and failed +invocation/toolchain/compile logs remain there too. + +The combined launch verified its expansion hashes against the pinned native115 +profile. The actual-input launcher check passed 38 checks; the static-only subset +remains 33 checks. The 95 primary-oracle preservation checks and 40 additional +geometry wiring checks also passed unchanged. + +| Evidence | Location Or SHA-256 | +| --- | --- | +| Compiled probe | `build/client-resource-probe/advantageclientresources-deobf.jar` | +| Probe SHA-256 | `B0E1519928878995E356E0035B3FAA620A750C08F06AD177C99E531147C706EE` | +| D combined launch SHA-256 | `C1E20DA39405F2DE503F251209D02C089170387AF692CC283D2071C68D47BE71` | +| D input preflight, no Java started | `build/client-resource-runs/20261026-130625-be14c356` | +| D full recipe/geometry run | `build/client-resource-runs/20261026-130632-3753a0c0` | +| Full `client-resources.json` SHA-256 | `18BE5F772EDBCDB17A1F33E68C7B2DB2F025D2EBEEAD625BA02695A84C1E87C8` | +| Geometry report SHA-256 | `027EFAAAE82DE28FF61738063C15C4E3D67BB5940BF04CA67ACDB776EAE2B053` | +| D fixture verification | `build/client-resource-compile-runs/final-d-fixture-preflight.json` | +| D runtime/input verification | `build/client-resource-compile-runs/final-d-client-provenance.json` | + +The D graphical run passed 94 item models/names, 905 valid block states and +87 creative entries. Both tank-floor faces remained visible. Native tank +simulation, 10,000 mB fill/drain and full/empty packets passed. The actual gauge +pixel was `1E386E`, satisfying the original blue thresholds. All six native +fluid cases passed; both oils used their own still/flowing sprites and `FFFFFF` +top-face tint. All 25 worldless child-renderer geometry cases passed with balanced +matrices; hydroturbine registration/texture passed with its explicit world gap. + +The full run used only the fresh D recipe campaign's `unlock` exports +under `test-rig/build/native115-recipes-20261026-d`. Before use, all five unlock +boots and all three packet reports were checked for `PASS`; the six packet/report +hashes and generated registry snapshot matched their fixture manifest. The saved +registry's candidate versions and all six candidate jar hashes matched target manifest +`3605B9BBF2B5C94C8278B31B5984969DD55B8F277E3B994496F045FA7CA79BE8`. +The native registry snapshot SHA-256 is +`73DF3EE41E8C5FC4C09DC7D9C79B221C127C749191C2900ABE4E61635B861383`. +The client independently validated the native 1.15.2/31.2.57 saved header and +decoded copied exported packets. No earlier campaign's packets were reused, and +the separate manufacturing progression runtime was not touched. This probe does +not qualify that campaign. + +| Recipe Mode | Recipes | Native Cells | Result | +| --- | --- | --- | --- | +| Normal | 93 | 77 | PASS | +| Tech | 87 | 77 | PASS | +| Apocalyptic | 82 | 73 | PASS | + +The actual thirteen-cell image contains 8,585 qualifying pixels against the +unchanged `> 1,000` assertion. Both the recipe and tank images were visually +inspected. Their SHA-256 values are +`C0868043B7C356BBFF80825662562F0AA2C0BB5C822DD36DEC2B1D71BFE3100D` +and `7D3A9295A75EDC67442A06B300A3077E37EE4335AAA94D48F65E22E14CE5A1A6`, +respectively. Each run retained its own reports, images, raw geometry, logs and +hashed production/launch/fixture inputs. The D result and both images are +byte-identical to the earlier full probe's outputs. The earlier preflight, +base-only run and full run remain at +`build/client-resource-runs/20261026-115710-d645702e`, +`build/client-resource-runs/20261026-115733-27d26ea0` and +`build/client-resource-runs/20261026-115941-52827e4b`; they are historical evidence, +not the D input record. No existing world was loaded. + +## Eclipse Compiler Verification + +Installed ECJ `3.46.0` (`v20260528-0407`) compiled all current production and test +sources into fresh, separate main/test outputs under +`build/ecj-final-d/20261026-130845/`. It ran on Java 25 with Java 8 source/target +and the actual Java 8 boot classpath. Every output class has major version 52. +The compiler jar SHA-256 is +`D0D43F8E2D7003E5EFED612E2CBB5F01870043397D8F1BBE536FD9128F4FCBF7`. + +| Project | Main/Test Sources | Main/Test Errors | Main/Test Warnings | Tests From ECJ Outputs | +| --- | --- | --- | --- | --- | +| Power | 139 / 24 | 0 / 0 | 97 / 1 | 66 PASS | +| Steam | 63 / 11 | 0 / 0 | 40 / 0 | 68 PASS | +| Electric | 85 / 16 | 0 / 0 | 52 / 0 | 45 PASS | + +`ecj-results.json` records source hashes, checkout refs, Eclipse classpath and +dependency hashes, commands and all six compiler results. Each project retains +`main.log`, `test.log` and XML diagnostics. `ecj-junit-results.json` and each +`junit.log` record successful Java 8 JUnit execution using the newly compiled ECJ +main/test classes, not Gradle's output classes. No tests were skipped. No +`bin/main`, `bin/test` or Gradle class output was replaced. The graphical probe +used the separately recorded Gradle production snapshot, not these ECJ outputs; +this does not claim an interactive Eclipse client launch. + +## Reproduce The Combined Run + +1. Use rebuilt native Steam/Electric candidates with matching combined hashes. + Run Gradle only when the shared `D:/GradleCaches/minecraft-mods-shared` cache + is free of other builds. Refresh pins after any production rebuild. Never substitute + existing 1.14 binaries or replay its fixture/evidence directories. +2. Build the probe and prepare the combined launch with the root tasks: + + ```powershell + ./gradlew.bat clientResourceProbeJar verifyEclipseProductionClasspath + ./gradlew.bat prepareCombinedEclipseLaunch + ./test-rig/client-resources/test-launcher.ps1 + ``` + +3. Use a new native115 recipe campaign's `unlock` directory. It must contain + all three `recipes-*-sweep.bin` packets, + corresponding successful JSON reports with `bookRecipes`, and generated + `AdvantageWorksMigration/level.dat`. The saved header must identify native + 1.15.2/31.2.57. The server campaign retains its five unlock/persistence boots + and eighteen progression checks; this probe does not generate those fixtures. +4. Snapshot inputs without starting Java: + + ```powershell + ./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -ChildRendererGeometry -RecipeBookFixtures '/unlock' -PreflightOnly + ``` + +5. Only when the graphical run is authorized, repeat the preceding command + without `-PreflightOnly`. Inspect the retained tank and recipe images and + native logs. Require all three + `advantage_mods`, exact discovered versions, `recipe_book_grouping_status=PASS`, + all six `fluid_rendering` cases, the tank results and the aggregate `PASS`. + Missing fixtures explicitly yield `NOT_RUN_NO_NATIVE_FIXTURES`, not combined + recipe qualification. Manual GUI navigation/discovery and real server-client + menu handshake acceptance remain separate and pending even after a probe pass. + +## Evidence And Safety + +Each run receives a fresh `build/client-resource-runs/-` directory. +The runner retains hashed selected production classes/resources, launch XML, +probe and candidate/dependency hashes, optional fixture hashes, native report, +images and separate stdout/stderr/native logs. Power's candidate jar hash is +provenance only: the runtime uses separately hashed Gradle or Eclipse output. +`-OutputSource Eclipse` copies `bin/main` without overlaying Gradle resources. + +Preflight is not client evidence. Native partial results/images are never +overwritten by runner failures; `runner-failure.json` is separate. Timeouts keep +the run and terminate only the launched process tree. Existing runs and all old +fixtures remain untouched. The client must remain at the main menu with no +player, world or integrated server; recipe registry import only reads the copied +disposable fixture's NBT, never opens it in Minecraft. No protected save is used. + +## Additional Renderer Geometry + +`-ChildRendererGeometry` adds a bounded CPU geometry check after all of the +existing resource, tank, oil and recipe oracles. It requires `-RequireExtensions` +and is off by default. Use it only with matching native child jars: + +```powershell +./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -RecipeBookFixtures '/unlock' -ChildRendererGeometry -PreflightOnly +``` + +After the required build checks and authorization for the graphical run, remove +`-PreflightOnly`. Compilation and the available worldless cases passed in the +full run recorded above. Its static wiring check is available without Gradle: + +```powershell +./test-rig/client-resources/test-geometry-contract.ps1 +``` + +All 40 static wiring/preservation checks passed. They do not execute the native +renderers; the separate combined-client run executed all 25 geometry cases and +recorded the one hydroturbine gap. + +The probe creates ordinary worldless tiles through the registered tile factories +and retrieves their actual renderers from `TileEntityRendererDispatcher`. There +are no Steam/Electric compile dependencies and no replacement renderers or calls +to geometry helpers. Reflection sets only mutable fixture inputs: drill rotation, +cached facing/beam length, and turret angle, charge and target state. This checks +rendering of those inputs, not the world/network logic that produces them. + +The real render methods receive native `MatrixStack` and `IRenderTypeBuffer` +inputs. Each selected production render type supplies a real `BufferBuilder`. +The resulting native buffers are decoded without uploading replacement geometry +or changing render state. Checks cover: + +- Steam drill bits in all six directions (all three axes), at partial ticks + `0` and `0.5`: 24 finite vertices, unit normals, unchanged UV bounds, cardinal + extent, block bounds and visible geometry movement between rotation frames. +- Electric drill beams in all six directions: 16 finite vertices, four-block + length in the requested direction, original `0.125` radius and beam UV layout. +- Turrets at idle and targeting all six directions: 24 body vertices when idle, + 40 body/beam vertices when firing, cardinal beam bounds, rotated body bounds, + and the distinct idle/active optic UV regions. +- Nondegenerate quads, nonzero alpha, finite attributes, normalized normals, + matrix depth/root/position/normal restoration, and readable expected textures + selected by the production render types. Matrix state and partial buffers are + retained even if rendering throws. The new geometry-only floating-point epsilon + is `0.0001`; none of the existing pixel thresholds or captures is changed. + +`child-renderer-geometry.json` retains per-case renderer identity, raw native +XYZ/UV/normal/alpha attributes, counts, bounds, texture dimensions, matrix results +and failures. Failures do not prevent the remaining bounded cases from running. +`primary_resource_status` records the existing oracles independently; a failure +in an enabled geometry case still fails the aggregate run. + +The hydroturbine's registered entity renderer and texture are checked directly +through the entity renderer manager. Its constructor reads `World.rand`, so +orientation/spin geometry cannot use an ordinary worldless entity. That case is +explicitly `MANUAL_GAP_WORLD_REQUIRED`, with no constructed entity or render +call. It needs a fresh disposable client-world test; constructor bypasses, +synthetic vertices and protected saves are not used. Successful execution of the +available cases therefore reports `PASS_WITH_MANUAL_GAPS`, never full child +renderer acceptance. This is buffer-geometry evidence, not pixel, render-state, +normal world-dispatch, interpolation/network or manual graphical acceptance. diff --git a/docs/1.15/CORE-RUNTIME.md b/docs/1.15/CORE-RUNTIME.md new file mode 100644 index 0000000..eb36422 --- /dev/null +++ b/docs/1.15/CORE-RUNTIME.md @@ -0,0 +1,137 @@ +# Native 1.15 Core Runtime Qualification + +## Candidate And Scope + +These are local packaged-server tests for Minecraft 1.15.2, Forge 31.2.57 and +Java 8. No 1.14 result is counted as a 1.15 pass. Graphical acceptance and the +separate actor, connected, endurance, recipe and interop campaigns are outside +this record. + +The sealed input is `test-rig/build/native115-inputs-20261026-final-d-archived.json`, +SHA-256 `3605B9BBF2B5C94C8278B31B5984969DD55B8F277E3B994496F045FA7CA79BE8`. +It is not rewritten or repinned during qualification. All jars are staged from +its hash-addressed archive paths, not mutable build output. + +Candidate D includes the startup recipe-condition registration repair. Its full +core matrix completed on 2026-10-26; earlier candidate A passes were not reused. + +| Artifact | SHA-256 | +| --- | --- | +| Power 2.4.6.115021 | `F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C` | +| Steam 2.2.7.115021 | `564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2` | +| Electric 2.2.7.115021 | `C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E` | +| Harness C 1.1.0.115021 | `B8F733F37B51A2EE8A0C11F21C1C84336FD8FCCDE998611DA334021C29CC34FA` | +| Base Metals | `EE951E23DAE0B7D98C13D8AE8738AC69ADDB7C096C26E51D1E55E5083C07E4DD` | +| OreSpawn | `8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913` | + +Campaigns run a fresh native world and then a disposable copy of the original-1.10 chained +source. The canonical source runtime at +`PA113/PowerAdvantage/run-qualification/20261023-current/matrix/original110-chained-113` +is never launched. Its archived profile and the accepted +`PA114/PowerAdvantage/test-rig/build/original110-chained-113-retained13-hashes.json` +are checked before staging. Frozen conversion must pass before live construction +is authorized. Complete source-runtime fingerprints are compared again afterward. + +## Engine Geometry + +The exact installed mapped source archive is +`.gradle/mavenizer/repo/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-sources-snapshot-20200514-1.15.1.jar`, +SHA-256 `937F0B7D7098D4D1A12EA306108122B1E443CA638D120D9040FF2B577E9A0314`. +It defines: + +- `ChunkManager.java:96`: `MAX_LOADED_LEVEL = 33 + ChunkStatus.maxDistance()`. +- `TicketManager.java:187`: a forced ticket at level 31. +- `ChunkStatus.java:95` and `:163`: eleven dependency ranges. +- `ChunkManager.java:425`: the actual queued Forge `ChunkEvent.Unload`. + +The native harness observes engine values and actual forced tickets without +loading witness chunks. The first real 1.15 load observation confirms FULL 33, +entity-ticking/forced level 31, distance 11, last holder level 44 and radius 13. +Every raw snapshot must agree with `native115_geometry.py`; independent auditing +also scans all raw snapshots, not only the selected canonical observations. +Matching 1.14 numbers are therefore an observed 1.15 result, not an assumption. + +## Phase Ledger + +All evidence directories below are under ignored `test-rig/build/`. + +| Phase | Evidence directory | Status | +| --- | --- | --- | +| Twelve-route lifecycle and all seven drill groups | `n115-core-20261026-lifecycle-d` | PASS in both worlds; independent audit PASS | +| Holder-aware partial networks and both load orders | `n115-core-20261026-holder-partial-d` | PASS in both worlds; independent audit PASS | +| Holder-aware pure-pipe bridge | `n115-core-20261026-holder-bridge-d` | PASS in both worlds; independent audit PASS | +| Eighteen-route extended movement and networks | `n115-core-20261026-holder-extended-d` | PASS in both worlds; independent audit PASS | +| Eighteen-route saved-read and unfinished-work guards | `n115-core-20261026-reads-d` | PASS in both worlds; independent audit PASS | +| Original adjacent-network control | `n115-core-20261026-original-partial-d` | Expected unload FAIL in both worlds; evidence audit PASS | +| Original empty-bay control | `n115-core-20261026-empty-control-d` | Expected unload FAIL in both worlds; evidence audit PASS | + +The independent auditor recomputes historical movement, mining, progress, +changed-target, partial-network, bridge, retained-identity and conservation +oracles from raw JSON. Canonical observations must be exact byte copies selected +by real chunk events and advancing world ticks, never by a desired verdict. +Failures, command exceptions, incomplete runs and setup timing warnings are +retained. New attempts use new output paths. + +## Findings + +- Lifecycle: all seven twelve-drill groups pass in each world, including five + genuine chunk cycles, normal mining, unfinished progress and full restarts. +- Partial networks: both unload directions and both load orders pass for steam, + electricity and fluid. Each world records 54 actual unloads and 54 traced + reloads, with no acquisition stack from Power's conduit-network code. +- Pipe-only bridge: all three cycles pass for all three networks in each world. + Disconnected fluid offers accept zero; measured reconnection offers retain + exactly 3,000 mB per world without replacing an endpoint. +- Extended drills: all eighteen routes complete four moves per world. Low + reserve, blocked output, retained inventory/energy/facing, output recovery and + redstone cases pass. Each world also passes all 21 restart-state cases. +- Saved reads: all eighteen routes pass unfinished-work chunk and process + reloads, legacy reads and changed-target guards, with exact tagged-item + conservation. Selection uses the first ready real-tick observation, not a + later convenient verdict. + +The original adjacent-network and empty-bay controls both retain FAIL results: +their endpoints never genuinely unload, so the strict reload and load-order +assertions also fail. All four control profiles have identical failure +signatures, including the worlds with no networks or machines in the bays. +This is the small fixture's forced-ticket limitation, not evidence that an +Advantage network reacquired a missing chunk. The holder-aware layouts clear +the verified thirteen-chunk dependency halo and pass the unchanged oracles. +No unload flags, events, counters, ticks or expected results were fabricated. + +Every D campaign passes its independent evidence audit and reports unchanged +protected source-runtime and protocol fingerprints. Normal save/stop sequences +are retained for every boot. Setup catch-up warnings are preserved in the +audits; these results do not claim lag-free region loading or replace the +separate endurance qualification. + +No unresolved core network or drill defect was found in this matrix. The last +control completed at `2026-10-26T13:52:49Z`. Client GUI/rendering acceptance, +including the hydroturbine world check, remains separate. + +## Superseded Candidate A + +Candidate A's lifecycle, holder partial, holder bridge and saved-read campaigns +passed in both worlds, including their independent audits. Those reports remain +under the corresponding `n115-core-20261026-*-a` directories. The original sealed +manifest `native115-inputs-20261026-current-archived.json` remains unchanged at +SHA-256 `5608065DAA5C45309C9A38D5E420A0E2EFCF2496E6D9F00DA7B5B1836EFDCBA7`. +These passes do not close the intermittent startup registration race observed +in the separate actor campaign. A's extended and control phases were not run; +the full matrix is repeated against D instead. + +## Test Tooling + +The adapters now accept core port 25580, and the isolated failure-retention unit +test no longer tries to bind an active server port. Both repairs have regression +coverage; earlier failed output is retained. The queued-unload comment now names +the verified 1.15 behavior. No gameplay or Java harness change was needed here. + +The independent auditor also covers the twelve-route lifecycle, every raw +engine-geometry observation and archived protocol fingerprints. Its controls +reject missing or altered proof and check that observations stay unchanged. +The focused native and historical-oracle suite has 106 passing tests, separate +from the packaged runtime results above. + +See [TEST-HARNESS.md](TEST-HARNESS.md) for the overall test matrix and rerun +commands. diff --git a/docs/1.15/CURRENT-QUALIFICATION.md b/docs/1.15/CURRENT-QUALIFICATION.md new file mode 100644 index 0000000..8d6e20d --- /dev/null +++ b/docs/1.15/CURRENT-QUALIFICATION.md @@ -0,0 +1,208 @@ +# Advantage 1.15.2 Test Record + +This is the local test record for Power Advantage 2.4.6.115021 and Steam and +Electric Advantage 2.2.7.115021 on Forge 31.2.57. It is not a published release. +The 1.14 versions were accepted separately; none of those results is counted +as a 1.15 pass. + +**Back up the whole world before upgrading. Use a disposable copy first.** +These tests cover the Advantage fixtures and their pinned dependencies, not +every mod or every possible player-built network. + +## Candidate + +| Release Jar | SHA-256 | +| --- | --- | +| PowerAdvantage-2.4.6.115021.jar | `F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C` | +| SteamAdvantage-2.2.7.115021.jar | `564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2` | +| ElectricAdvantage-2.2.7.115021.jar | `C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E` | + +The current test installation uses local Base Metals 3.0.2.115021 and OreSpawn +4.1.1.115021. Mineralogy-specific tests use Mineralogy 6.0.1.115021. +Production and dependency jars are archived by hash before testing; a later +build cannot silently replace a running test's inputs. + +Base Metals 3.0.2 fixes an intermittent world-data +hook registration race found during these tests. Its 64 tests, release audits +and Eclipse checks pass. The earlier failed migration remains in the record; +the affected migration, operating and endurance checks have been repeated +with this exact dependency. The Advantage production jar hashes above have +not changed. + +The same direct-registration pattern is present in the read-only Base Metals +1.14 reference. That is a separate follow-up risk, not a reproduced 1.14 +failure or permission to change its published release. This fix covers the +local 1.15 branch only. + +## Results + +| Check | Result | Scope | +| --- | --- | --- | +| Release suites and Eclipse classpaths | PASS | Power 66, Steam 68 and Electric 45 tests; Java 8 bytecode, resources, dependencies and archive checks | +| Cold-cache, paths with spaces and reproducible builds | PASS | Earlier cold bootstrap plus two current-jar exports per mod; twelve matching artifact pairs, pinned JDKs, and no downloaded toolchains | +| Frozen world conversion | PASS with corrected dependency | All fourteen F original/maintained direct and chained routes, including a newly generated 1.14 source; full restart and idempotence | +| Connected old machinery | PASS with corrected dependency | All ten F routes; preservation and two 512-tick intervals across full restart, without rebuilding or refilling | +| Recipes and manufacturing | PASS with corrected dependency | Fresh F exports: five unlock boots and eighteen progression checks; all three modes, aliases, reload and restart | +| Native energy, fluids, portable tanks and locks | PASS on previous dependency | Simulation, exact conservation, invalidation, retained contents and full restart; unchanged Advantage jar bytes | +| Mining, loot and block properties | PASS on previous dependency | 271 strict cases per boot, two boots; real survival tools, silk touch and all wire/scaffold connection masks | +| Chunk lifecycle, partial networks and extended drills | PASS on previous dependency | Five complete campaigns; two deliberately invalid controls rejected; fresh and migrated worlds with real chunk events | +| Players, entities, dimensions and hook ordering | Complete with explicit vanilla failures | F: 108 clean qualifying boots, 8 actor routes, 6 storage layouts, 6 ordering cases and 72 respawn cases pass; strict wolf-health comparisons remain FAIL | +| Complete walkable Works | PASS with corrected dependency | All 21 bays rerun in F current/decoupled profiles; 96/95 passing assertions, 3/4 explicit skips; all bays retain manual checklists | +| Oil transport | PASS on preceding input set | Six water/Advantage/Mineralogy lanes; exact bucket/drain/pipe/tank quantities across two full restarts; this profile contains no Base Metals | +| World generation | PASS with corrected sampler | All three F profiles, including Mineralogy and legacy import, complete and retain exact counts across restart | +| Thirty-minute operation | PASS with corrected dependency | Both F worlds complete 1,801.58 seconds, full restart and resumed operation; raw conservation and strict-log audits pass | +| Client resources and recipe-book rendering | PASS with manual gaps | Actual 1.15 client; 94 items, 905 states, both floor faces, gauges, six fluids, three recipe modes and 25 geometry cases | +| Maintainer visuals and recipe unlocks | ACCEPTED 2026-10-27 | In-game visuals and unlocks reported good; machine functionality relies on the recorded automated checks | +| Remaining interactive checks | Not individually qualified | The bounded tests and visual acceptance do not establish every control, weapon, menu or multiplayer interaction | + +The harness passes 95 Java tests and the final Python adapter suite passes 575 +tests. These validate the test machinery; they are not counted as machine or +world-migration cases. + +The actor fixtures expose a vanilla migration difference: a tamed wolf saved +with 8 health loads with 20. Exact health preservation remains a failed check; +it is not hidden by accepting an item or machine test. The final actor record +retains the engine source explanation and affected routes. + +The completed F dimension matrix has sixteen route executions with eighty +exact-item checks and passing load/save/shutdown controls. Its wolf-health +differences remain strict failures. A separate failed respawn attempt exposed +the harness rewriting a live JSON definition while Java read it. The adapter +now publishes complete JSON by same-directory atomic replacement; red/green +tests prove that readers never see a truncated file. The three failed boot logs +are retained and excluded from qualification, and the complete respawn matrix +was rerun from untouched sources. No item expectations, retries or Java +machine code were changed for that repair. + +The specialized core, mining and capability results retain their original D +dependency manifest. The Advantage binaries are identical, and the Base Metals +patch changes only its hook registration, version metadata and two packaged +documentation references. The F reruns target that startup/migration change +and repeat the complete Works, recipes, client, frozen/connected migrations, +actor matrix and endurance. The table does not relabel older dependency runs. + +## Evidence + +Raw reports remain local under ignored `test-rig/build/`. Failed attempts are +retained, and reruns use new directories. The current archived manifest is +`native115-inputs-20261026-final-f-archived.json`, SHA-256 +`A9BCD5A442F6332010F9D467BD00A685AA953339C39545812B32B825B96E64AA`. +It uses harness E, SHA-256 +`C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B`. +Base Metals is pinned to local commit `ac1781e7`, release checksum +`3F50AFBEF17800ECADE42A69B81E541E9FA8C6C4C392ED4DD0FE5A66DC9EFF07`. +Earlier manifests and runs remain separate; a passing older run is not a +claim that the corrected dependency has been tested in that same setup. + +| Evidence Directory | Purpose | +| --- | --- | +| `native115-frozen-20261026-f` | Fourteen frozen conversion routes with the corrected dependency | +| `native115-connected-20261026-f` | Ten connected operating routes with the corrected dependency | +| `native115-broad-20261026-f` | Full current and decoupled Works with the corrected dependency | +| `native115-recipes-20261026-d` | Recipes, discovery and progression | +| `native115-interop-20261026-d` | Native capabilities, tanks and locks | +| `native115-harvest-20261026-d` | Real survival drops and fixture isolation | +| `native115-endurance-20261026-f-isolated` | Full measured endurance, restart and independent raw audit; failed D run retained separately | + +Current client evidence is indexed by +`build/client-resource-compile-runs/final-f-full-client-provenance.json`. +The earlier D run remains at +`build/client-resource-runs/20261026-130632-3753a0c0/`. +The recipe capture has 8,585 qualifying pixels against the unchanged +`>1,000` threshold. Fresh ECJ production and test outputs passed all three +mods' 66/68/45 tests; selected Power class/resources matched its development +jar byte-for-byte. +Cold-cache bootstrap evidence is under +`D:\PowerAdvantage\_verification\pa115-clean-cold-20261026\Fixed JDK retry\`. +Both exports passed the full release gates using Java 8u502 for compilation +and tests. The earlier system-property attempt is retained but does not prove +toolchain isolation. The corrected run passes Gradle project properties; +automatic detection and download are both disabled. Its source revisions are +Power `e3da24b`, Steam `bced14a` and Electric `a73d1ad`; subsequent workflow +guards passed locally. Candidate D additionally repairs recipe-condition +registration, the shared creative-tab guard and Steam's native track/platform +properties. Both independent D exports passed 66/68/45 tests and every release +gate, with all twelve artifact pairs identical. The D report is under +`D:\PowerAdvantage\_verification\pa115-clean-cold-20261026\Candidate D\`. +The missing local Steam recipe-regression red log was reproduced separately +from its exact old source and old Power pin; that new record is labelled as a +reproduction, not the original log. + +The first endurance run was stopped because its candidate was superseded. Its +18 measured batches and save-only cleanup are retained, but it is explicitly +`SUPERSEDED_INCOMPLETE`, not a 30-minute pass. Later runs qualify only their +own full 1,800 seconds, restart and resumed operation. + +Candidate D then completed all 30 batches in both worlds over 1,801.58 seconds, +with 36,536/36,033 game ticks, full restart and resumed processing. Its overall +report remains **FAIL**: the fresh setup logged +`PointOfInterestData: POI data mismatch: never registered at (-126,5,27)` +before the Works finished construction. The normal strict log auditor rejects +it. Successful quantities are useful evidence, but do not erase that failure. + +Candidate F then passed both worlds for 1,801.58 seconds, with 36,617/35,992 +ticks, full process restart and resumed processing. The independent raw audit +reconstructs 33 checkpoints per world. The fresh fixture uses the original +failing seed but disables structures before creating the blank test world; +raw `level.dat` proves `MapFeatures=0` and the old composter coordinate is air +before construction. The imported world's existing structure setting remains +`MapFeatures=1`. No POI entries, existing-world data, expected quantities, +timing thresholds or watchdog settings were changed to obtain the pass. + +The original Mineralogy world-generation scan requested and counted 3,267 +chunks in one synchronous test command. Its tick exceeded the unchanged +60-second watchdog. The resulting counts are retained but do not qualify +that failed run. The harness was changed to distribute the same work +across ticks, preserving centers, radius, population halo, height range, +quantities and biome checks; no production-mod fix or watchdog relaxation +was used to obtain a pass. + +The bounded Mineralogy rerun completes both scans of 3,267 chunks and keeps +exact counts across its restart: 6,759 Power oil, 8,945 Mineralogy oil, 2,975 +lithium ore and 47,136 Mineralogy sulfur ore blocks, with zero Electric sulfur +and zero oil biome violations. These are observations from the new world, +not a claim that every count equals the earlier watchdog-failed world's count. +See [client checks](CLIENT-RESOURCES.md), [core runtime checks](CORE-RUNTIME.md), +[mining checks](HARVEST-BASELINE.md) and [repeatable commands](TEST-HARNESS.md). + +## Manual Checks + +The worldless renderer probe cannot construct a real hydroelectric turbine +because that entity needs a world. Its renderer and texture are registered, +but its in-world orientation and animation remain manual checks. Real-world +render dispatch, animation interpolation, screen interaction and live menu +synchronization also need personal acceptance. + +The practical manual checklist is: + +- Check creative-tab entries, names, models, transparency and tooltips. +- Collect the raw recipe ingredients and inspect recipe-book unlocks in the intended mode. +- Open real machine screens while working and after restarting, comparing visible gauges with operation. +- Check in-world drills, the turbine, turret beams and elevator motion. +- Try elevator controls, musket use and turret ownership/targeting in a disposable proving ground. +- Join with a real client and check dimension travel, the death screen and respawn. Server tests use a dummy network handler. + +The available tests are bounded examples, not every possible factory layout, +redstone circuit, recursive fabricator recipe graph or third-party modpack. +No BaseMinerals 1.15 jar is included in this tested set, so its material-owner +combination remains outside runtime qualification rather than an implied pass. +A dedicated set of 1.14-origin player/entity/storage/respawn fixtures has not +been generated. The passing 1.14-origin frozen and connected machine fixtures +are separate evidence, not a substitute for those actor cases. +A complete Sylvester modpack upgrade is not established by these bounded +Advantage fixtures; unrelated mods need their own matching ports and checks. + +Import the three `PA115` repositories into Eclipse as existing Gradle projects. +Power's generated `runClientAllAdvantage.launch` loads all three; its ordinary +`runClient.launch` remains Power-only. Preparing either launch does not open, +convert or approve a saved world. + +Final release and Eclipse checks were rerun for all three projects using the +normal shared cache, `D:\GradleCaches\minecraft-mods-shared`. Their Buildship +settings no longer point at the private reproducibility cache. The disposable +[manual server](MANUAL-TESTING.md) is prepared and passes `-CheckOnly`; it has +not been started. + +The maintainer authorized source pushes and merges on 27 October 2026 after +accepting the visuals and recipe unlocks. The remaining limits above stay +explicit. A source merge does not authorize a tag, CurseForge upload or release. diff --git a/docs/1.15/HARVEST-BASELINE.md b/docs/1.15/HARVEST-BASELINE.md new file mode 100644 index 0000000..386889a --- /dev/null +++ b/docs/1.15/HARVEST-BASELINE.md @@ -0,0 +1,41 @@ +# Mining and Block Properties + +The 1.15 checks use real survival block breaking, not just the presence of loot +JSON. Failed tools must produce no items; successful tools and silk touch must +retain the historical drops. Every wire and scaffold connection mask is tested. + +The first run was refused because its disposable runtime lacked +`advantage-works.allow`. The launcher now creates both required marker files. +The world-name and operator guards remain unchanged, and the refused run is +retained separately. + +## Solidness Reference + +A new test initially assumed that every narrow block was non-solid. That is +not how the 1.14 engine behaves: ordinary electric wires are solid but have a +narrow collision shape. The lattice scaffold and steel frame are non-solid. +This distinction must not be changed merely to satisfy a test. + +An isolated Java check instantiated the actual accepted 1.14 classes against +their original Forge classpath, without loading a world: + +| Component | `isSolid` | Render Layer | +| --- | --- | --- | +| Electric wire | `true` | Solid | +| Electric scaffold | `false` | Cutout | +| Photovoltaic generator | `true` | Solid | +| Steel frame | `false` | Cutout | + +The accepted Electric development jar is identified by SHA-256 +`2B32B667092D3BFBC4CF905AE0C630C2767B3C6E191B62035FF2ED0CDEEBF05E`. +The check source has SHA-256 +`03743141CB0A72D0344B05B15CCB65DC2B2CCC60F95285635C4D46C8684975E5`; +the captured output has SHA-256 +`76A55A91E180190CAB4758D8D7EEEC1327131EFFD688520AD60B2D60A106315C`. +The local evidence is retained under +`D:\PowerAdvantage\_verification\pa115-harvest-baseline14-20261026`. + +Only the newly introduced wire-solidness expectation was corrected. Harvest +levels, failed-tool checks, drop quantities, silk-touch checks, collision bounds, +connection masks and the solar-panel check remain strict. No production block +was changed to accommodate the mistaken expectation. diff --git a/docs/1.15/MANUAL-TESTING.md b/docs/1.15/MANUAL-TESTING.md new file mode 100644 index 0000000..8233942 --- /dev/null +++ b/docs/1.15/MANUAL-TESTING.md @@ -0,0 +1,223 @@ +# Native 1.15 Manual Testing + +## Prepared Copy + +The disposable manual server is prepared, **not launched**: + +```text +D:\PowerAdvantage\PA115\PowerAdvantage\test-rig\build\native115-manual-20261026-f +``` + +It contains the existing 21-bay Works world copied from the stopped, passed F +`native115-broad-20261026-f/packaged-current` runtime. The copied world is the +authoritative starting point for this manual session, including the machines, +supplies and saved state left by the broad test. It was not rebuilt, reset, +refilled or opened in Minecraft during preparation. This is a bounded manual +fixture, not another automated qualification or permission to open other saves. + +The whitelist copy includes the world, config/defaultconfigs, libraries, all six +pinned F server mods, including Base Metals `3.0.2.115021` and harness E, plus +the Forge launcher, vanilla server jar, existing EULA, +properties, account lists and the two Works/live-operation markers. It excludes +old logs, result directories, verification JSON, migration-probe authorization +and cached mixin output. `ops.json`, whitelist, ban lists and user cache were +copied unchanged. No operator or external account was changed. + +Only `server-port` changed, from `25592` to `25565`. The address remains +`127.0.0.1`; query and RCON remain disabled. The copied fixture retains +`online-mode=false`, creative mode and its existing local test configuration. +Do not expose it to a network, tunnel, public address or production players. + +## Start And Stop + +In a local PowerShell console, check the prepared inputs without launching Java: + +```powershell +& 'D:\PowerAdvantage\PA115\PowerAdvantage\test-rig\build\native115-manual-20261026-f\Start-Manual.ps1' -CheckOnly +``` + +When ready to run the manual server, use the same command without `-CheckOnly`: + +```powershell +& 'D:\PowerAdvantage\PA115\PowerAdvantage\test-rig\build\native115-manual-20261026-f\Start-Manual.ps1' +``` + +The launcher checks the exact prepared path beneath `test-rig/build`, rejects +links, verifies the local manifest and every pinned mod, library, launcher, +vanilla jar and marker, and rejects extra mod/library files or probe markers. +Properties are checked semantically as described below, not by their initial +file hash. It uses only this Java 8 home, with executable and release hashes: + +```text +C:\Users\johnb\.jdks\temurin-8.0.502+7\jdk8u502-b07 +``` + +Native 1.15.2 startup reloads and saves `server.properties` through +`ServerPropertiesProvider` and `PropertyManager`. Forge's `SortedProperties` +writes sorted keys using Java `Properties.store()`, which adds a new timestamp. +The installed native source and Java 8 source were checked directly. A raw file +hash would therefore reject an ordinary second start. The original properties +hash remains preparation evidence only; comments, timestamp, line endings and +entry order may change without blocking restart. + +These eight critical properties must be present exactly once with their +canonical values: + +| Property | Required Value | +| --- | --- | +| `level-name` | `AdvantageWorksMigration`, exactly; no other name or path | +| `server-ip` | `127.0.0.1` | +| `server-port` | `25565` | +| `online-mode` | `false` | +| `enable-query` | `false` | +| `enable-rcon` | `false` | +| `max-tick-time` | `60000` | +| `gamemode` | `creative` | + +The validator intentionally accepts only this fixture's plain ASCII `key=value` +format, with optional surrounding key/separator whitespace, blank lines and +Java Properties `#`/`!` comment lines. Trailing value whitespace is not trimmed. +Duplicate keys anywhere, escaped keys/values, continuation lines, alternate +separators, BOM/non-ASCII bytes and malformed assignments fail closed. This is +not a general Java Properties parser. Noncritical values are not pinned by this +semantic gate, but must still use that plain syntax. The native writer's normal +output for this prepared fixture satisfies it. + +World files and account lists are mutable during normal play. Their initial +hashes are evidence, not startup gates. The launcher requires the copied +`level.dat` to exist and keeps the exact world name, path and no-link checks; +it does not compare the played world to its initial bytes. Jar, library, marker, +manifest, Java and launcher-location checks remain unchanged. + +It refuses to launch if any local TCP listener already uses port 25565. The +check runs again immediately before Java, but does not reserve the port. If a +competing process binds after that check, the wrapper also detects the native +`FAILED TO BIND TO PORT` message in this launch's refreshed `logs/latest.log`, +even if Java returns zero. Nonzero Java exits remain errors. Java runs in the +foreground with this manual copy as its working directory, without an installer, +Gradle, background process, +automatic commands or access to the original runtime. Missing/changed immutable +inputs and unsafe critical properties are errors; do not bypass the guards or +substitute jars to make startup succeed. + +Wait for the server's `Done` message before connecting. Keep the console open. +To finish, disconnect the client and enter these commands at the server console, +without leading slashes: + +```text +save-all flush +stop +``` + +Wait for saving and shutdown to complete before closing the console or copying +this world again. If startup or a manual check fails, retain this runtime, its +logs and the observed error. Do not overwrite the original broad evidence. + +## Eclipse Client + +Use Eclipse workspace `D:\PowerAdvantage\PA115` and the native Power, Steam and +Electric projects. In the Power project, select `runClientAllAdvantage` from +the Eclipse launch configurations (`runClientAllAdvantage.launch`). Use the +known Java 8 runtime above and current production classes. Eclipse classpath +preparation passed for all three projects; ECJ's separate verification outputs +are not a replacement for Eclipse's own `bin/main` output. + +The combined profile contains the pinned F Steam/Electric development jars, +Base Metals `3.0.2.115021` and +the native 1.15.2 / Forge 31.2.57 runtime. Do not add packaged server jars to the +client's mods folder, duplicate Power, or select an old launch or protected save. +Starting the Eclipse client alone need not connect to any server. To inspect +this prepared Works copy, start the manual server separately, then use +Multiplayer / Direct Connect to `127.0.0.1:25565`. Connection and native menu +handshakes are part of manual acceptance, not claimed by preparation. + +Stay in the Works bays and proving grounds. The existing world already contains +the 21 stations; do not run `advworks build all` to initialize it again. Rebuild +and reset commands replace bounded fixture state. The server controller's +in-memory station status is not a persisted qualification result. + +The server console can inspect `advworks status` or operate one selected bay. +Clickable bay controls require operator permission. No permission was granted +during preparation. `SkyBlade1978` is not automatically made an operator; any +grant must be requested separately and entered in the running server console. + +## What Remains Manual + +The F automated combined-client probe passed resource/model/locale checks, +both tank-floor faces, actual gauge pixels, six native fluid cases and 25 +worldless child-renderer geometry cases. All three recipe modes passed using +fresh F exports; the thirteen-cell capture contains 8,585 qualifying pixels +against the unchanged `> 1,000` assertion. The separate F native recipe campaign +passed all five unlock boots and 18 progression checks. All original pixel +thresholds and captures are retained. Fresh F ECJ production/test compilation +and tests passed for all three projects. See [Client Resources](CLIENT-RESOURCES.md) +for exact scope, pins and retained evidence. + +Those results do not close the remaining manual checks: + +- Actual menu opening, server/client handshake, normal GUI use and recipe-book + discovery/navigation during gameplay. +- Hydroturbine orientation and spin in an actual disposable client world; its + worldless constructor requires `World.rand`, so no geometry pass was claimed. +- Normal world-dispatched rendering, animation/interpolation, render state and + gameplay observation of drills, beams, turbines and turrets. +- The manual observations on all 21 Works bay cards, including bounded machine + operation and the relevant start/stop behavior. + +The post-test copy is not a fresh supply set. Record what was changed during a +manual session and distinguish new observations from the retained automated +reports. The copy and any later manual changes are disposable, never new release +qualification by themselves. + +## Preparation Evidence + +The source had a clean save/shutdown log, no listener on its port 25592 and an +exclusively readable session lock. Its complete 190-file fingerprint was equal +before and after the whitelist copy. All 132 copied source files initially +matched, before the one documented port edit. No server was launched. + +The manual directory contains: + +- `manual-inputs.json`: copied whitelist, exclusions, initial world/account + hashes, original properties hash, fixed launch inputs, source report identity + and F manifest hash. This original manifest is unchanged. +- `manual-evidence/source-fingerprint-before.json` and + `manual-evidence/source-fingerprint-after.json`: complete original-runtime + fingerprints, retained separately and equal. +- `manual-evidence/source-fingerprint-final.json`: source fingerprint rechecked + after the no-launch guard tests, still equal to the initial source. +- `manual-evidence/final-f-inputs.json`: byte-identical immutable F input manifest. +- `manual-evidence/preparation-results.json`: passing final checksum and + `-CheckOnly` verification; the source and retained D copy are unchanged. +- `manual-evidence/restart-guards/20261026-144547-1cb04885`: 29 passing F checks, + including real `-CheckOnly` tests for comment/reorder/store + fixtures, all eight changed critical fields, unsafe world paths, missing keys, + duplicates, escaping, continuations and trailing value whitespace. Wrong hash, + missing input, relocated script and foreground/port guards remain covered. + +Those tests temporarily changed only this manual copy's properties and restored +the exact initial bytes in `finally`. No Java/server was started, no world was +opened or edited, and no operator was changed. Bind-exit controls use explicitly +synthetic log fixtures; they are not live bind or server-restart evidence. + +| Evidence | SHA-256 | +| --- | --- | +| F input manifest | `A9BCD5A442F6332010F9D467BD00A685AA953339C39545812B32B825B96E64AA` | +| Passed source broad report | `1E898C091E5B6FD83C259D8A64445C53493CD3E97499ECEA830043344EFDDDE9` | +| Initial before/after source fingerprint files | `84F736DE8C8F3E44A855EA3268194EB690DD2D8DDF016E2BB07B8B18690798ED` | +| Manual input manifest | `629EBED1F8607D8EC25F790011B3C7203152F32FCDEB0DE1C193D3CB01EB1FC6` | +| Initial properties, evidence only | `1FD9D2E19D225D537393AF6316B5C15E39414D754372AF3FFC5B6393DA43F646` | +| Current Start-Manual.ps1 | `CC79BF63A0FD2FFB3CBE1847DE40F4064585499840BAC14BC00AD00519C56F90` | + +The separate `native115-manual-20261026-d` directory is retained unchanged, +including all 216 files and its earlier red/green restart-guard evidence. +It is not the F manual runtime. Its +`manual-evidence/restart-guards/native-properties-source-evidence.json` and +extracted source text document the same native 1.15.2 reload/save/sorting, +Java timestamp behavior and native bind-error handling used by F. Those source +checks are retained D evidence, not a claim that F was started or restarted. +Use only F's current root `Start-Manual.ps1`, not archived scripts. + +The world is expected to change only when a human starts and uses this copy. +The source fingerprint is a preparation check, not a claim that a later manual +session left the copied world unchanged. diff --git a/docs/1.15/PORT-STATUS.md b/docs/1.15/PORT-STATUS.md new file mode 100644 index 0000000..7ab185a --- /dev/null +++ b/docs/1.15/PORT-STATUS.md @@ -0,0 +1,77 @@ +# Advantage 1.15.2 Port + +The three versions are Power Advantage **2.4.6.115021** and Steam and +Electric Advantage **2.2.7.115021**. The [test record](CURRENT-QUALIFICATION.md) +identifies the exact jars, completed checks and remaining limits. +The maintainer accepted visuals and recipe unlocks on 27 October 2026. +Machine functionality is supported by the bounded automated checks, not a +claim that every possible setup was played manually. + +**Back up the entire world before upgrading. Test a disposable copy first.** +The Advantage fixtures do not qualify an entire modpack or another mod's +conversion support. + +## Platform + +| Setting | Target | +| --- | --- | +| Minecraft / Forge | 1.15.2 / 31.2.57 | +| Snapshot mappings / MCP | 20200514-1.15.1 / 20200515.085601 | +| ForgeGradle / Gradle / Renamer | 7.0.34 / 9.6.1 / 1.1.5 | +| Gradle / production / Mavenizer JDK | Java 17 / Java 8 / Java 25 | +| Base Metals / OreSpawn | 3.0.2.115021 / 4.1.1.115021 | + +The implementation uses native activation results, block properties, render +layers, matrix stacks and vertex buffers. Thin machinery stays non-solid, +solar panels stay solid, and steam tracks and elevator platforms retain their +old light and redstone behavior. Recipe-condition registration runs on Forge's +loading thread, and the shared creative tab has one initialization lock. + +Mod IDs, registry and fluid names, saved-state keys, recipe identities and +legacy public API packages are retained. The migration hook is retargeted to +1.15; actual preservation and operation are tested separately from compilation. + +## Source + +The upstream and fork `master-1.15.2` branches started from the merged 1.14 +versions: + +| Mod | Starting MMD Commit | Local Feature Branch | +| --- | --- | --- | +| Power | `ef08e5cd134dc75bd73518bf4c90a6711e67280f` | `feature/poweradvantage-1.15.2-port` | +| Steam | `d0bbc53ab26870ab975f80230f92b8b29e6667a8` | `feature/steamadvantage-1.15.2-port` | +| Electric | `0f34225d871e5804bc0ece2f6c1b5482824a3039` | `feature/electricadvantage-1.15.2-port` | + +The production jar bytes first came from Power `75d24d2`, Steam `7883d9b` and +Electric `f39a7f8`. The dependency update is committed locally as Power +`6666099`, Steam `07f6a05` and Electric `58bb1a7`. Steam and Electric pin +Power's exact source and development-jar checksum in `gradle.properties`. + +Base Metals `3.0.2.115021` source `ac1781e7` fixes the shared world-hook +registration race found by the migration tests. CI checks out that exact +source before building Advantage. Dependency classes are not embedded. + +## Manual Testing + +Use `D:\PowerAdvantage\PA115` as a separate Eclipse workspace. Import the +three nested repositories as existing Gradle projects: + +- `PowerAdvantage` +- `SteamAdvantage\SteamAdvantage` +- `ElectricAdvantage\ElectricAdvantage` + +Refresh Gradle, let Eclipse finish building, then use Power's generated +`runClientAllAdvantage.launch`. Its ordinary `runClient.launch` remains +Power-only. Both use Power's `run` directory; preparing a launch never opens +or converts a world. + +Models, fluid screens and recipe-book cells have automated client probes, +but in-world animation, controls, live menus, real player login and the +hydroturbine still need personal acceptance. The test record lists known +vanilla differences and the boundaries of the server fixtures. + +## Delivery + +The maintainer authorized the 1.15 source merges after accepting the visual +and recipe-unlock checks. Source publication follows the fork-first PR process. +Tags, CurseForge uploads and other release publication remain separate actions. diff --git a/docs/1.15/TEST-HARNESS.md b/docs/1.15/TEST-HARNESS.md new file mode 100644 index 0000000..15867d3 --- /dev/null +++ b/docs/1.15/TEST-HARNESS.md @@ -0,0 +1,141 @@ +# Repeating the 1.15.2 Tests + +Work from `D:\PowerAdvantage\PA115\PowerAdvantage`. The harness is a separate +test mod, never part of a Power Advantage release. The +[current test record](CURRENT-QUALIFICATION.md) separates build, operating, +migration and manual results. Earlier Minecraft results do not qualify a +1.15 candidate. + +## Setup And Inputs + +```powershell +$py = 'D:\PowerAdvantage\PA113\PowerAdvantage\test-rig\migration\.venv\Scripts\python.exe' +$j8 = 'C:\Users\johnb\.jdks\temurin-8.0.502+7\jdk8u502-b07\bin\java.exe' +$old = 'D:\PowerAdvantage\PA114\PowerAdvantage\test-rig\build' +$seed = 'D:\PowerAdvantage\_verification\pa115-native-seed-20261026' +$target = 'test-rig/build/native115-inputs-20261026-final-f-archived.json' +$m = 'test-rig/migration' +$env:PYTHONDONTWRITEBYTECODE = '1' +$env:TEMP = Join-Path $PWD 'test-rig/build/python-temp' +$env:TMP = $env:TEMP +New-Item -ItemType Directory -Path $env:TEMP -Force | Out-Null +``` + +The [current test record](CURRENT-QUALIFICATION.md) lists the three production +jar hashes. The archived F manifest pins those unchanged jars, the local +Base Metals 3.0.2 world-hook fix, OreSpawn 4.1.1 and harness E. Older manifests +and failed runs remain intact. Missing or changed pins fail before staging; +create a new manifest and output directory for a different candidate. + +Harness E 1.1.0.115021 uses bounded world-generation sampling. Its 95 tests +cover sampling order and counts, fresh completion reports and owned-process +cleanup. The sample radius, halo, height range, watchdog and expected results +are unchanged: + +| Artifact | SHA-256 | +| --- | --- | +| Release | `C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B` | +| Deobf | `767450DF1743A0E14414970C791FC8FA0EF54E61CAECDC9C13FB52A27DE7D8E3` | +| Runner | `21DF61B5501D3E5E0EB10394773C5D86C5AFCEB478AE09426AA8B980F267C361` | + +Harness build logs and the retained red/green cleanup tests are under +`D:\PowerAdvantage\_verification\worldgen-sampling-e-20261026\runner-cleanup\`. +Use quoted complete arguments for paths with spaces. To select local JDKs, +pass `-Porg.gradle.java.installations.paths=`, +`-Porg.gradle.java.installations.auto-detect=false` and +`-Porg.gradle.java.installations.auto-download=false`. JVM `-D` options +do not enforce these Gradle project settings. + +The recorded candidate is immutable throughout campaigns. Do not rerun +pinning against an existing output or +modify a running campaign's inputs. The CPU-only Python command is independent. + +```powershell +./gradlew.bat -p test-rig test assemble verifyHarnessIsolation +& $py -B -m unittest discover -s test-rig/migration -p 'test_*.py' +./gradlew.bat -p test-rig verifyWorksNative115Inputs +# Only for a NEW future candidate, never overwrite the sealed $target: +# & $py "$m/native115.py" pin-target --server-seed $seed --output +``` + +Hash-address and freeze all candidate jars before another build can replace +them. Input manifests are immutable. Outputs below must not already exist; +use a new run suffix after any failed attempt. + +## Frozen And Connected Sources + +These are the exact retained final 114 proof paths. Their `sourceHashes` refer +to original historical worlds, NOT approval of native114 destination worlds. + +```powershell +& $py "$m/native115_campaign.py" prepare --frozen-evidence "$old/native114-broader-frozen-20261025-i/native114-refresh-results.json" --connected-evidence "$old/native114-broader-connected-20261025-i/native114-refresh-results.json" --output test-rig/build/native115-source-plan-a +# Completed source generation used the following NEW output after retaining -a: +# & $py "$m/native115_campaign.py" generate114 --routes test-rig/build/native115-source-plan-a/native115-upgrade-routes.json --java8 $j8 --port 25595 --output test-rig/build/native115-disposable114-b +$routes = 'test-rig/build/native115-disposable114-b/native115-upgrade-routes.json' +& $py "$m/native115_campaign.py" run frozen --routes $routes --target-manifest $target --java8 $j8 --port 25595 --output test-rig/build/native115-frozen-a +& $py "$m/native115_campaign.py" run connected --routes $routes --target-manifest $target --java8 $j8 --port 25595 --output test-rig/build/native115-connected-a +``` + +Required: 14 frozen cases (13 original/current 110/112 direct/chained/113, +plus a new accepted114 comprehensive 355-block/332-tile source) and ten connected +cases (five historical plus five disposable native114 sources). Each connected +destination preserves its initial state and operates 512+512 ticks across a full +restart, without rebuilding/refilling. A freshly fingerprinted old destination +cannot replace a generated source receipt. + +The `-b` bundle is source-ready: all six disposable114 receipts and all 24 +route inputs passed the final read-only readiness audit. Routes SHA-256: +`A5241F5F41426DC57DD85A15B62B211415BE8B69D004154F8FC47DDF239B35C2`. +Audit: `test-rig/build/native115-disposable114-b/source-readiness.json`. +The comprehensive fixture retained 355 blocks, 332 tiles and 2,654 item witnesses. +All five connected sources passed two 512-tick intervals, preservation and +clean restarts with every lane, both drills and battery present. Canonical +world/runtime protections passed. Source readiness is not a native115 pass. + +Failed `native115-disposable114-a` is retained. Its actual comprehensive +conversion/restart/idempotence passed, but receipt recording rejected metadata +whose parent directory overlapped the new runtime. Metadata now uses the +disjoint `source-records/` sibling; the original guard remains strict and four +focused source-receipt tests pass. Never reuse or overwrite failed output paths. + +## Other Suites + +Each command takes `--target-manifest $target --java8 $j8 --output `. +Lifecycle/endurance additionally take the explicit source, archived source +manifest/key and approved hashes from the route plan. Never use copied matrix +convenience defaults or treat smoke duration as endurance. + +| Runner | Required Scope | Ports | +| --- | --- | --- | +| `native115_actors.py --kind actors` | All eight historical actor routes; approved disposable114 counterparts when prepared | 25584 | +| `native115_actors.py --kind dimensions` | Nether and End, all routes, frozen/restart/live state | 25584, sequential | +| `native115_player_layout.py` | Inventory/ender storage and layout audit | 25584, sequential | +| `native115_respawn.py` | Nether/End death, bed/forced/missing spawn, keepInventory, restart | 25584, sequential | +| `native115_transformer_order.py` | Both source versions, every original hook order | 25584 after actors | +| `native115_holder_networks.py --campaign partial/bridge/extended` | Real holder unload/load and retained drill/network oracles | 25580 | +| `native115_ticket_networks.py --campaign partial/bridge/extended` | Full-status halo protocol, distinct from holder-unload proof | 25581 | +| `native115_lifecycle.py`, `native115_partial.py`, `native115_extended.py` | Chunk/movement/mining/progress/read guards and reload order | 25580 sequential | +| `native115_recipes.py --campaign all` | Discovery, packets, all modes, progression, reload/restart | 25577 | +| `native115_oil.py` | Four lanes; six with pinned Mineralogy; exact refill/two restarts | 25576 | +| `native115_interop.py`, `native115_harvest.py` | Capabilities/portable loot/locks and real tool/silk drops | 25592 / 25596 | +| `native115_broad.py --profile both` | Current and decoupled full works | 25588 | +| `native115_endurance.py --seconds 1800` | Both fresh/copied environments and independent conservation audit | 25590-25591 | +| `verifyWorksWorldgenAdvantage`, `verifyWorksWorldgenLegacyImport`, `verifyWorksWorldgenMineralogy` | Native ownership/import/user overrides and restart; distinct Gradle runs | exclusive Gradle slot | + +Original actor/Nether/End/respawn/order proof roots are respectively +`native114-broader-actors-20261025-i`, `native114-broader-dimensions-{nether,end}-20261025-i`, +`native114-broader-respawn-{nether,end}-20261025-i`, and +`native114-broader-order-20261025-i` under `$old`. Reuse their original approved +fixture provenance; do not freshly approve those destination saves. Client +resource/render/recipe-button probes belong to the separate client agent. + +## Concurrency And Results + +Start with two server processes; four is the absolute ceiling, not a target. +Keep frozen/source-generation/connected phases sequential. Actor/dimension/ +respawn/order jobs may overlap only with distinct ports AND distinct NEW writable +runtimes and immutable shared sources. One order or dimension process can +contain several sequential boots. Endurance reserves its port pair. No parallel +Gradle tasks. Measure free disk before source staging; do not delete evidence to +recover space without approval. Archive each report's pins, original source +fingerprints, actual native ticket observations, failure logs and restart proof. diff --git a/gradle.properties b/gradle.properties index 31855df..4f68382 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,15 +5,15 @@ org.gradle.caching=true org.gradle.parallel=false net.minecraftforge.gradle.merge-source-sets=false -minecraft_version=1.14.4 -minecraft_version_range=[1.14.4] -forge_version=28.2.26 -forge_installer_sha256=E68A0E2D589B2954C5D109A04B80747AC8C061ACABE074DB1607F9C2FD7D2CE1 -forge_version_range=[28,) -loader_version_range=[28,) +minecraft_version=1.15.2 +minecraft_version_range=[1.15.2] +forge_version=31.2.57 +forge_installer_sha256=84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773 +forge_version_range=[31.2.57,32) +loader_version_range=[31,) mapping_channel=snapshot -mapping_version=20190719-1.14.3 -mcp_version=20190829.143755 +mapping_version=20200514-1.15.1 +mcp_version=20200515.085601 loader_name=forge loader_code=1 @@ -26,21 +26,21 @@ curseforge_project_id=240982 mod_id=poweradvantage mod_name=Power Advantage mod_license=LGPL-3.0-only -mod_version=2.4.5.114041 +mod_version=2.4.6.115021 mod_group=zone.moddev.mc.poweradvantage 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.14.4 +cf_versions=1.15.2 -basemetals_version=3.0.1.114041 -basemetals_source_commit=713802f93f2d1f650e1593fa35c487ea43add4a5 +basemetals_version=3.0.2.115021 +basemetals_source_commit=ac1781e7cade15045ffe0135077087b4d1722603 basemetals_curse_project_id=240967 -basemetals_deobf_sha256=A14A87E3BF30964F901E715A9B4EF2F1E7317ABE7F9ACEBE60D8E6A900CA6EB2 +basemetals_deobf_sha256=426FE639C97E1DE4F6624957E3CBDE68A5FB7D415A1047903A29DCB35FD9ADA2 -orespawn_version=4.1.1.114041 -orespawn_source_commit=9c2488cba02d317e3f31e24441dcc58273d8d0e6 +orespawn_version=4.1.1.115021 +orespawn_source_commit=5613baf876747ed071cb7ad7e2aff4e0ce4f0a03 orespawn_curse_project_id=245586 -orespawn_deobf_sha256=1807C3A43F6E854F4EC5741F75A52750B733CD81DC588CF0F666C6C3A489FBEF +orespawn_deobf_sha256=358B13375A4B60622C491D6729290971A446FF14D524A600CA13710934DB3FDC diff --git a/src/main/java/cyano/poweradvantage/api/ConduitBlock.java b/src/main/java/cyano/poweradvantage/api/ConduitBlock.java index b3cd267..aca85fa 100644 --- a/src/main/java/cyano/poweradvantage/api/ConduitBlock.java +++ b/src/main/java/cyano/poweradvantage/api/ConduitBlock.java @@ -34,7 +34,11 @@ protected ConduitBlock(Material mat) { } protected ConduitBlock(Material mat, float hardness) { - super(Properties.create(mat).hardnessAndResistance(hardness)); + this(Properties.create(mat).hardnessAndResistance(hardness)); + } + + protected ConduitBlock(Properties properties) { + super(properties); } /** diff --git a/src/main/java/cyano/poweradvantage/api/GUIBlock.java b/src/main/java/cyano/poweradvantage/api/GUIBlock.java index 62e3f97..9ab2225 100644 --- a/src/main/java/cyano/poweradvantage/api/GUIBlock.java +++ b/src/main/java/cyano/poweradvantage/api/GUIBlock.java @@ -12,6 +12,7 @@ import net.minecraft.block.BlockRenderType; import net.minecraft.util.Direction; import net.minecraft.util.Hand; +import net.minecraft.util.ActionResultType; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; import net.minecraft.world.IBlockReader; @@ -44,7 +45,7 @@ public GUIBlock(Material m) { } public GUIBlock(Material m, float hardness) { - this(Block.Properties.create(m).hardnessAndResistance(hardness).variableOpacity()); + this(Block.Properties.create(m).hardnessAndResistance(hardness).notSolid().variableOpacity()); } protected GUIBlock(Block.Properties properties) { @@ -108,12 +109,6 @@ public boolean isOpaqueCube(BlockState bs) { return false; } - /** 1.13 uses this flag, not the legacy opacity overload, to cull neighbours. */ - @Override - public boolean isSolid(BlockState state) { - return false; - } - /** * 3 = normal block (model specified in assets folder as .json model)
* -1 = special renderer @@ -132,31 +127,31 @@ public BlockRenderType getRenderType(BlockState bs) { */ @SuppressWarnings("deprecation") @Override - public boolean onBlockActivated(BlockState bs, World w, BlockPos coord, + public ActionResultType onBlockActivated(BlockState bs, World w, BlockPos coord, PlayerEntity player, Hand hand, net.minecraft.util.math.BlockRayTraceResult hit) { Direction facing = hit.getFace(); if (w.isRemote) { - return true; + return ActionResultType.SUCCESS; } final TileEntity tileEntity = w.getTileEntity(coord); if (tileEntity == null || player.isSneaking()) { - return false; + return ActionResultType.PASS; } // handle buckets and fluid containers net.minecraft.item.ItemStack item = player.getHeldItem(hand); if(item != null && !item.isEmpty() && tileEntity instanceof IAdvantageFluidHandler && FluidContainerHelper.interact(player, hand, (IAdvantageFluidHandler) tileEntity, facing)) { - return true; + return ActionResultType.SUCCESS; } // open GUI - if(this.getGuiOwner() == null) return false; + if(this.getGuiOwner() == null) return ActionResultType.PASS; if(tileEntity instanceof PoweredEntity && player instanceof ServerPlayerEntity){ ((PoweredEntity)tileEntity).syncPowerAdvantageDataTo((ServerPlayerEntity)player); } - if (!(player instanceof ServerPlayerEntity)) return false; + if (!(player instanceof ServerPlayerEntity)) return ActionResultType.PASS; MachineGUIRegistry.open((ServerPlayerEntity) player, w, coord, this.getGuiID()); - return true; + return ActionResultType.SUCCESS; } diff --git a/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerConduit.java b/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerConduit.java index 9ebc29b..5a266f3 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerConduit.java +++ b/src/main/java/cyano/poweradvantage/api/simple/BlockSimplePowerConduit.java @@ -31,7 +31,12 @@ public abstract class BlockSimplePowerConduit extends ConduitBlock { public BlockSimplePowerConduit(Material material, float hardness, float pipeRadius, ConduitType... energyTypes) { - super(material, hardness); + this(Block.Properties.create(material).hardnessAndResistance(hardness), pipeRadius, energyTypes); + } + + protected BlockSimplePowerConduit(Block.Properties properties, float pipeRadius, + ConduitType... energyTypes) { + super(properties); this.types = energyTypes; this.pipeRadius = pipeRadius; setDefaultState(getStateContainer().getBaseState() diff --git a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java index 1e26dfc..7832e37 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java +++ b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java @@ -262,14 +262,14 @@ protected void drawGuiContainerBackgroundLayer(float f, int i, int j) { this.blit(x, y, 0, 0, playerInventoryWidth, playerInventoryHeight); this.minecraft.getTextureManager().bindTexture(guiDisplayImage); this.blit(x, y, 0, 0, guiWidth, guiHeight); - drawGUIDecorations(entity, this, x, y, this.blitOffset); + drawGUIDecorations(entity, this, x, y, this.getBlitOffset()); } /** * Gets the z-coordinate of this GUI element * @return screen depth coordinate */ public float getZLevel(){ - return this.blitOffset; + return this.getBlitOffset(); } /** * Gets the width of the GUI diff --git a/src/main/java/zone/moddev/mc/poweradvantage/PowerAdvantage.java b/src/main/java/zone/moddev/mc/poweradvantage/PowerAdvantage.java index ebfba63..245d111 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/PowerAdvantage.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/PowerAdvantage.java @@ -37,12 +37,12 @@ import zone.moddev.mc.poweradvantage.migration.AdvantageLegacyMigrations; import zone.moddev.mc.poweradvantage.migration.LegacyWorldDataHook; -/** Forge 1.14 entry point for Power Advantage. */ +/** Forge 1.15 entry point for Power Advantage. */ @Mod(PowerAdvantage.MODID) public final class PowerAdvantage { public static final String MODID = "poweradvantage"; public static final String NAME = "Power Advantage"; - public static final String VERSION = "2.4.5.114041"; + public static final String VERSION = "2.4.6.115021"; public static final Logger LOGGER = LogManager.getLogger(MODID); private static PowerAdvantage instance; @@ -53,7 +53,7 @@ public final class PowerAdvantage { public static float chestLootFactor = 0.5F; public static boolean plasticIsAlsoRubber = true; - /** All historical RF- and Tech Reborn-labelled converters use Forge Energy in 1.14. */ + /** All historical RF- and Tech Reborn-labelled converters use Forge Energy in 1.15. */ public static final Map forgeEnergyConversionTable = new HashMap<>(); /** Conversion table for the historical Tech Reborn-labelled Forge Energy converter. */ @@ -91,14 +91,16 @@ public PowerAdvantage() { migrateLegacyOreSpawnConfiguration(); migrateLegacyConfiguration(); ModLoadingContext.get().registerConfig(ModConfig.Type.COMMON, PowerAdvantageConfig.SPEC); - RecipeConditions.register(); FMLJavaModLoadingContext.get().getModEventBus().addListener(PowerAdvantageConfig::onConfigLoading); FMLJavaModLoadingContext.get().getModEventBus().addListener(this::commonSetup); DistExecutor.runWhenOn(Dist.CLIENT, () -> () -> { FMLJavaModLoadingContext.get().getModEventBus().addListener( (net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent event) -> - net.minecraftforge.fml.DeferredWorkQueue.runLater(MachineGUIRegistry::registerScreens)); + net.minecraftforge.fml.DeferredWorkQueue.runLater(() -> { + MachineGUIRegistry.registerScreens(); + zone.moddev.mc.poweradvantage.client.ClientOilRenderer.configureRenderLayers(); + })); zone.moddev.mc.poweradvantage.client.ClientOilRenderer.register(); }); } @@ -137,6 +139,8 @@ private static void migrateLegacyConfiguration() { } private void commonSetup(FMLCommonSetupEvent event) { + // CraftingHelper's shared condition map is not safe during parallel loading. + net.minecraftforge.fml.DeferredWorkQueue.runLater(RecipeConditions::register); // Forge's shared hook list is not safe to mutate during parallel mod construction. net.minecraftforge.fml.DeferredWorkQueue.runLater(LegacyWorldDataHook::register); PowerAdvantageConfig.apply(); diff --git a/src/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java b/src/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java index ce22941..5878ecf 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java @@ -5,7 +5,6 @@ import net.minecraft.block.material.Material; import net.minecraft.block.BlockState; import net.minecraft.entity.player.PlayerEntity; -import net.minecraft.util.BlockRenderLayer; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; import net.minecraft.world.IWorldReader; @@ -22,13 +21,7 @@ public class BlockFrame extends Block { public BlockFrame(Material material, float hardness, float resistance) { super(Block.Properties.create(material).sound(SoundType.METAL) - .hardnessAndResistance(hardness, resistance).variableOpacity()); - } - - @OnlyIn(Dist.CLIENT) - @Override - public BlockRenderLayer getRenderLayer() { - return BlockRenderLayer.CUTOUT; + .hardnessAndResistance(hardness, resistance).notSolid().variableOpacity()); } public boolean isFullCube(BlockState bs) { diff --git a/src/main/java/zone/moddev/mc/poweradvantage/client/ClientOilRenderer.java b/src/main/java/zone/moddev/mc/poweradvantage/client/ClientOilRenderer.java index ae315e5..49c579e 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/client/ClientOilRenderer.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/client/ClientOilRenderer.java @@ -1,6 +1,8 @@ package zone.moddev.mc.poweradvantage.client; import net.minecraft.client.renderer.texture.AtlasTexture; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.RenderTypeLookup; import net.minecraft.util.ResourceLocation; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; @@ -17,8 +19,24 @@ public static void register() { MinecraftForge.EVENT_BUS.addListener(ClientOilRenderer::stitch); } + public static void configureRenderLayers() { + RenderTypeLookup.setRenderLayer(zone.moddev.mc.poweradvantage.init.Blocks.steel_frame, + RenderType.getCutout()); + for (net.minecraft.fluid.Fluid fluid : new net.minecraft.fluid.Fluid[] { + zone.moddev.mc.poweradvantage.init.Fluids.crude_oil, + zone.moddev.mc.poweradvantage.init.Fluids.flowing_crude_oil, + zone.moddev.mc.poweradvantage.init.Fluids.refined_oil, + zone.moddev.mc.poweradvantage.init.Fluids.flowing_refined_oil}) { + RenderTypeLookup.setRenderLayer(fluid, RenderType.getTranslucent()); + } + RenderTypeLookup.setRenderLayer(zone.moddev.mc.poweradvantage.init.Blocks.crude_oil_block, + RenderType.getTranslucent()); + RenderTypeLookup.setRenderLayer(zone.moddev.mc.poweradvantage.init.Blocks.refined_oil_block, + RenderType.getTranslucent()); + } + private static void stitch(TextureStitchEvent.Pre event) { - if (event.getMap() != net.minecraft.client.Minecraft.getInstance().getTextureMap()) return; + if (!AtlasTexture.LOCATION_BLOCKS_TEXTURE.equals(event.getMap().getTextureLocation())) return; for (String name : new String[] {"crude_oil_still", "crude_oil_flow", "refined_oil_still", "refined_oil_flow"}) { event.addSprite(new ResourceLocation(PowerAdvantage.MODID, "blocks/" + name)); } diff --git a/src/main/java/zone/moddev/mc/poweradvantage/gui/FluidTankGUI.java b/src/main/java/zone/moddev/mc/poweradvantage/gui/FluidTankGUI.java index 9595c8d..bf74748 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/gui/FluidTankGUI.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/gui/FluidTankGUI.java @@ -64,8 +64,8 @@ public static void drawFluidFilledRectangle( ) { if (fs == null || fs.getFluid() == null || width <= 0 || height <= 0) return; // The atlas supplies the current animation frame; water's sprite is grayscale. - TextureAtlasSprite sprite = guiContainer.getMinecraft().getTextureMap() - .getSprite(fs.getFluid().getAttributes().getStillTexture()); + TextureAtlasSprite sprite = guiContainer.getMinecraft().getAtlasSpriteGetter(AtlasTexture.LOCATION_BLOCKS_TEXTURE) + .apply(fs.getFluid().getAttributes().getStillTexture()); guiContainer.getMinecraft().getTextureManager().bindTexture(AtlasTexture.LOCATION_BLOCKS_TEXTURE); int color = fs.getFluid().getAttributes().getColor(zone.moddev.mc.poweradvantage.util.ForgeFluidAdapter.toForge(fs)); if (fs.getFluid().isEquivalentTo(net.minecraft.fluid.Fluids.WATER)) { @@ -82,8 +82,8 @@ public static void drawFluidFilledRectangle( int tileHeight = Math.min(16, height - dy); for (int dx = 0; dx < width; dx += 16) { int tileWidth = Math.min(16, width - dx); - double u0 = sprite.getMinU(), u1 = sprite.getInterpolatedU(tileWidth); - double v0 = sprite.getMinV(), v1 = sprite.getInterpolatedV(tileHeight); + float u0 = sprite.getMinU(), u1 = sprite.getInterpolatedU(tileWidth); + float v0 = sprite.getMinV(), v1 = sprite.getInterpolatedV(tileHeight); vertexbuffer.pos(x + dx, y + dy + tileHeight, zLevel).tex(u0, v1).color(red, green, blue, 1F).endVertex(); vertexbuffer.pos(x + dx + tileWidth, y + dy + tileHeight, zLevel).tex(u1, v1).color(red, green, blue, 1F).endVertex(); vertexbuffer.pos(x + dx + tileWidth, y + dy, zLevel).tex(u1, v0).color(red, green, blue, 1F).endVertex(); diff --git a/src/main/java/zone/moddev/mc/poweradvantage/init/Fluids.java b/src/main/java/zone/moddev/mc/poweradvantage/init/Fluids.java index 586093a..7b1e11a 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/init/Fluids.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/init/Fluids.java @@ -11,7 +11,6 @@ import net.minecraft.fluid.IFluidState; import net.minecraft.item.Item; import net.minecraft.state.StateContainer; -import net.minecraft.util.BlockRenderLayer; import net.minecraft.util.Direction; import net.minecraft.util.ResourceLocation; import net.minecraft.util.math.BlockPos; @@ -139,8 +138,7 @@ private static ForgeFlowingFluid.Properties properties(Kind kind) { .bucket(() -> kind == Kind.CRUDE ? Items.crude_oil_bucket : Items.refined_oil_bucket) .block(() -> kind == Kind.CRUDE ? Blocks.crude_oil_block : Blocks.refined_oil_block) .slopeFindDistance(4).levelDecreasePerBlock(1) - .tickRate(kind == Kind.CRUDE ? 15 : 10).explosionResistance(100.0F) - .renderLayer(BlockRenderLayer.TRANSLUCENT); + .tickRate(kind == Kind.CRUDE ? 15 : 10).explosionResistance(100.0F); } public static final class Flowing extends PowerFluid { diff --git a/src/main/java/zone/moddev/mc/poweradvantage/init/ItemGroups.java b/src/main/java/zone/moddev/mc/poweradvantage/init/ItemGroups.java index 5b07076..a41a737 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/init/ItemGroups.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/init/ItemGroups.java @@ -14,7 +14,7 @@ public class ItemGroups { private static boolean initDone = false; - public static void init() { + public static synchronized void init() { if (initDone) return; // tab_powerAdvantage = FunctionalCreativeTab.create(PowerAdvantage.MODID) diff --git a/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/BlockConveyor.java b/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/BlockConveyor.java index c1f80b9..e7eafe6 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/BlockConveyor.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/BlockConveyor.java @@ -23,7 +23,7 @@ public class BlockConveyor extends GUIBlock { public BlockConveyor(Material material, float hardness) { super(Block.Properties.create(material).hardnessAndResistance(hardness) - .sound(SoundType.METAL).variableOpacity()); + .sound(SoundType.METAL).notSolid().variableOpacity()); setDefaultState(getStateContainer().getBaseState().with(FACING, Direction.NORTH)); } diff --git a/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/BlockConveyorFilter.java b/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/BlockConveyorFilter.java index 47c7cb6..0a45803 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/BlockConveyorFilter.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/BlockConveyorFilter.java @@ -26,7 +26,7 @@ public class BlockConveyorFilter extends GUIBlock { public BlockConveyorFilter(Material material, float hardness, Class tileEntityClass) { super(Block.Properties.create(material).hardnessAndResistance(hardness) - .sound(SoundType.METAL).variableOpacity()); + .sound(SoundType.METAL).notSolid().variableOpacity()); this.tileEntityClass = tileEntityClass; setDefaultState(getStateContainer().getBaseState().with(FACING, Direction.NORTH)); } diff --git a/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/StillBlock.java b/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/StillBlock.java index 68450ad..b1b1544 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/StillBlock.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/StillBlock.java @@ -95,7 +95,7 @@ private Direction metaToFacing(int i) { } @Override - public int getLightValue(BlockState bs, net.minecraft.world.IEnviromentBlockReader world, BlockPos coord) { + public int getLightValue(BlockState bs, net.minecraft.world.IBlockReader world, BlockPos coord) { return bs.get(ACTIVE) ? 12 : 0; } diff --git a/src/main/resources/META-INF/coremods.json b/src/main/resources/META-INF/coremods.json index 9de3539..59b136e 100644 --- a/src/main/resources/META-INF/coremods.json +++ b/src/main/resources/META-INF/coremods.json @@ -1,3 +1,3 @@ { - "poweradvantage_114_migration": "coremods/poweradvantage_114_migration.js" + "poweradvantage_115_migration": "coremods/poweradvantage_115_migration.js" } diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index f430b5e..0810e2a 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -14,14 +14,14 @@ description='''API and shared machines for pressure, electricity, quantum power, [[dependencies.poweradvantage]] modId="basemetals" mandatory=false -versionRange="[3.0.1.114041,4.0.0)" +versionRange="[3.0.1.115021,4.0.0)" ordering="AFTER" side="BOTH" [[dependencies.poweradvantage]] modId="orespawn" mandatory=true -versionRange="[4.1.1.114041,5.0.0)" +versionRange="[4.1.1.115021,5.0.0)" ordering="AFTER" side="BOTH" diff --git a/src/main/resources/coremods/poweradvantage_114_migration.js b/src/main/resources/coremods/poweradvantage_115_migration.js similarity index 99% rename from src/main/resources/coremods/poweradvantage_114_migration.js rename to src/main/resources/coremods/poweradvantage_115_migration.js index 0c4cdf8..1de8cc2 100644 --- a/src/main/resources/coremods/poweradvantage_114_migration.js +++ b/src/main/resources/coremods/poweradvantage_115_migration.js @@ -6,7 +6,7 @@ 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 the pre-datafix migration needs bytecode integration on Forge 1.14. +// Only the pre-datafix migration needs bytecode integration on Forge 1.15. var hook = 'zone/moddev/mc/poweradvantage/migration/LegacyWorldDataHook'; function initializeCoreMod() { diff --git a/src/main/resources/pack.mcmeta b/src/main/resources/pack.mcmeta index 50efb50..04bbbd2 100644 --- a/src/main/resources/pack.mcmeta +++ b/src/main/resources/pack.mcmeta @@ -1,6 +1,6 @@ { "pack": { "description": "Power Advantage resources", - "pack_format": 4 + "pack_format": 5 } } diff --git a/src/test/java/zone/moddev/mc/poweradvantage/migration/LegacyTransformerOrderTest.java b/src/test/java/zone/moddev/mc/poweradvantage/migration/LegacyTransformerOrderTest.java index 966773d..7616ece 100644 --- a/src/test/java/zone/moddev/mc/poweradvantage/migration/LegacyTransformerOrderTest.java +++ b/src/test/java/zone/moddev/mc/poweradvantage/migration/LegacyTransformerOrderTest.java @@ -46,7 +46,7 @@ private static void check(boolean baseFirst, boolean productionNames) throws Exc private static void transform(ClassNode node, String target, boolean base) throws Exception { String mod = base ? "basemetals" : "poweradvantage"; - String path = base ? "/coremods/basemetals_114_compatibility.js" : "/coremods/poweradvantage_114_migration.js"; + String path = base ? "/coremods/basemetals_115_compatibility.js" : "/coremods/poweradvantage_115_migration.js"; ScriptEngine engine = new ScriptEngineManager().getEngineByName("nashorn"); assertNotNull("Transformer tests require the Java 8 test toolchain", engine); try (InputStreamReader reader = new InputStreamReader(getResource(path), StandardCharsets.UTF_8)) { engine.eval(reader); } diff --git a/src/test/java/zone/moddev/mc/poweradvantage/resources/BuildWorkflowContractTest.java b/src/test/java/zone/moddev/mc/poweradvantage/resources/BuildWorkflowContractTest.java index 923f190..785c0ec 100644 --- a/src/test/java/zone/moddev/mc/poweradvantage/resources/BuildWorkflowContractTest.java +++ b/src/test/java/zone/moddev/mc/poweradvantage/resources/BuildWorkflowContractTest.java @@ -7,6 +7,7 @@ import java.nio.file.Paths; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; public class BuildWorkflowContractTest { @Test public void pinnedDependencyCheckoutPolicyPrecedesCheckout() throws Exception { @@ -16,9 +17,22 @@ public class BuildWorkflowContractTest { int policy = workflow.indexOf("run: git config --global core.autocrlf false"); int checkout = workflow.indexOf("- name: Check out pinned Base Metals"); assertTrue(name, policy >= 0 && checkout > policy); - assertTrue(name, workflow.contains("ref: 713802f93f2d1f650e1593fa35c487ea43add4a5")); - assertTrue(name, workflow.contains("ref: 9c2488cba02d317e3f31e24441dcc58273d8d0e6")); - assertTrue(name, workflow.contains("master-1.14.4")); + assertTrue(name, workflow.contains("ref: ac1781e7cade15045ffe0135077087b4d1722603")); + assertTrue(name, workflow.contains("BaseMetals-3.0.2.115021-deobf.jar")); + assertTrue(name, workflow.contains("ref: 5613baf876747ed071cb7ad7e2aff4e0ce4f0a03")); + assertTrue(name, workflow.contains("master-1.15.2")); + assertTrue(name, workflow.contains("java-version: '25.0.3+9.0.LTS'")); + assertTrue(name, workflow.contains("java-version: '17.0.1+12'")); + assertTrue(name, workflow.contains("java-version: '8.0.502+7'")); + assertTrue(name, workflow.contains("-Porg.gradle.java.installations.paths=")); + assertTrue(name, workflow.contains("-Porg.gradle.java.installations.auto-detect=false")); + assertTrue(name, workflow.contains("-Porg.gradle.java.installations.auto-download=false")); + assertFalse(name, workflow.contains("-Dorg.gradle.java.installations.")); + for (String line : workflow.split("\\R")) { + if (line.contains("./gradlew ") && !line.contains("chmod")) { + assertTrue(name + ": " + line, line.contains("./gradlew $MMD_GRADLE_TOOLCHAINS ")); + } + } } } } diff --git a/src/test/java/zone/moddev/mc/poweradvantage/resources/CreativeTabInitializationTest.java b/src/test/java/zone/moddev/mc/poweradvantage/resources/CreativeTabInitializationTest.java new file mode 100644 index 0000000..b960916 --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/resources/CreativeTabInitializationTest.java @@ -0,0 +1,13 @@ +package zone.moddev.mc.poweradvantage.resources; + +import java.lang.reflect.Modifier; +import org.junit.Test; +import zone.moddev.mc.poweradvantage.init.ItemGroups; +import static org.junit.Assert.assertTrue; + +public class CreativeTabInitializationTest { + @Test public void addOnsCannotRaceTheSharedOneTimeTabInitialization() throws Exception { + assertTrue("All three constructors share one initialization lock", + Modifier.isSynchronized(ItemGroups.class.getMethod("init").getModifiers())); + } +} diff --git a/src/test/java/zone/moddev/mc/poweradvantage/resources/NativeMachinePropertyTest.java b/src/test/java/zone/moddev/mc/poweradvantage/resources/NativeMachinePropertyTest.java new file mode 100644 index 0000000..48c91c2 --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/resources/NativeMachinePropertyTest.java @@ -0,0 +1,59 @@ +package zone.moddev.mc.poweradvantage.resources; + +import cyano.poweradvantage.api.GUIBlock; +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.simple.BlockSimplePowerConduit; +import net.minecraft.block.Block; +import net.minecraft.block.material.Material; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.registry.Bootstrap; +import net.minecraft.world.IBlockReader; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.poweradvantage.machines.conveyors.BlockConveyor; +import zone.moddev.mc.poweradvantage.machines.conveyors.BlockConveyorFilter; +import zone.moddev.mc.poweradvantage.machines.conveyors.TileEntityConveyor; +import static org.junit.Assert.*; + +public class NativeMachinePropertyTest { + @BeforeClass public static void bootstrapNativeRegistries() { Bootstrap.register(); } + + @Test public void materialConstructorsKeepTheLegacyNonSolidDefault() { + assertFalse(new MaterialMachine().getDefaultState().isSolid()); + } + + @Test public void explicitNativePropertiesRetainBothSolidAndNonSolidChoices() { + assertTrue(new PropertyMachine(Block.Properties.create(Material.ROCK)).getDefaultState().isSolid()); + assertFalse(new PropertyMachine(Block.Properties.create(Material.ROCK).notSolid()) + .getDefaultState().isSolid()); + } + + @Test public void conveyorsRemainNonSolidWhenUsingExplicitNativeProperties() { + assertFalse(new BlockConveyor(Material.PISTON, 0.75F).getDefaultState().isSolid()); + assertFalse(new BlockConveyorFilter(Material.PISTON, 0.75F, TileEntityConveyor.class) + .getDefaultState().isSolid()); + } + + private static final class MaterialMachine extends GUIBlock { + MaterialMachine() { super(Material.PISTON, 0.75F); } + @Override public TileEntity createNewTileEntity(IBlockReader world) { return null; } + } + + @Test public void conduitSubclassesCanChooseNativePropertiesWithoutReplacingNetworkBehavior() { + PropertyConduit solid = new PropertyConduit(Block.Properties.create(Material.PISTON)); + PropertyConduit nonSolid = new PropertyConduit(Block.Properties.create(Material.PISTON).notSolid()); + assertTrue(solid.getDefaultState().isSolid()); + assertFalse(nonSolid.getDefaultState().isSolid()); + assertEquals(6, nonSolid.getDefaultState().getProperties().size()); + assertEquals("electricity", nonSolid.getTypes()[0].toString()); + } + + private static final class PropertyMachine extends GUIBlock { + PropertyMachine(Block.Properties properties) { super(properties); } + @Override public TileEntity createNewTileEntity(IBlockReader world) { return null; } + } + + private static final class PropertyConduit extends BlockSimplePowerConduit { + PropertyConduit(Block.Properties properties) { super(properties, 0.125F, new ConduitType("electricity")); } + } +} diff --git a/src/test/java/zone/moddev/mc/poweradvantage/resources/NativePlatformContractTest.java b/src/test/java/zone/moddev/mc/poweradvantage/resources/NativePlatformContractTest.java index 1b5cd72..c3ba6ac 100644 --- a/src/test/java/zone/moddev/mc/poweradvantage/resources/NativePlatformContractTest.java +++ b/src/test/java/zone/moddev/mc/poweradvantage/resources/NativePlatformContractTest.java @@ -20,7 +20,7 @@ public class NativePlatformContractTest { 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_114_migration.js", coremods.get("poweradvantage_114_migration").getAsString()); + assertEquals("coremods/poweradvantage_115_migration.js", coremods.get("poweradvantage_115_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"))); } diff --git a/src/test/java/zone/moddev/mc/poweradvantage/resources/RecipeRegistrationThreadTest.java b/src/test/java/zone/moddev/mc/poweradvantage/resources/RecipeRegistrationThreadTest.java new file mode 100644 index 0000000..dec7169 --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/resources/RecipeRegistrationThreadTest.java @@ -0,0 +1,49 @@ +package zone.moddev.mc.poweradvantage.resources; + +import java.io.InputStream; +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Handle; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InvokeDynamicInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import static org.junit.Assert.*; + +public class RecipeRegistrationThreadTest { + @Test public void sharedForgeConditionsAreRegisteredAfterParallelSetup() throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = getClass().getResourceAsStream("/zone/moddev/mc/poweradvantage/PowerAdvantage.class")) { + assertNotNull(input); + new ClassReader(input).accept(node, 0); + } + String conditions = "zone/moddev/mc/poweradvantage/recipe/RecipeConditions"; + int queued = 0; + for (MethodNode method : node.methods) { + boolean registrationRunnable = false; + for (AbstractInsnNode instruction : method.instructions.toArray()) { + if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + assertFalse("Forge's condition HashMap is not safe during parallel loading", + call.owner.equals(conditions) && call.name.equals("register")); + if (call.owner.equals("net/minecraftforge/fml/DeferredWorkQueue") + && call.name.equals("runLater") && registrationRunnable) { + assertEquals("Register conditions during common setup", "commonSetup", method.name); + queued++; + registrationRunnable = false; + } + } + if (instruction instanceof InvokeDynamicInsnNode) { + for (Object argument : ((InvokeDynamicInsnNode) instruction).bsmArgs) { + if (argument instanceof Handle && ((Handle) argument).getOwner().equals(conditions) + && ((Handle) argument).getName().equals("register")) { + registrationRunnable = true; + } + } + } + } + } + assertEquals("Queue registration exactly once on Forge's loading thread", 1, queued); + } +} diff --git a/test-rig/NATIVE115.md b/test-rig/NATIVE115.md new file mode 100644 index 0000000..15981dc --- /dev/null +++ b/test-rig/NATIVE115.md @@ -0,0 +1,267 @@ +# Native 1.15.2 Harness + +Forge 31.2.57, snapshot 20200514-1.15.1, MCP 20200515.085601, +ForgeGradle 7.0.34 / Gradle 9.6.1. Gradle runs on Java 17; Minecraft and +harness 1.1.0.115021 use Java 8. Compilation needs only the exact pinned Power +2.4.6.115021 deobf jar. Steam/Electric 2.2.7.115021 are runtime inputs. + +Default tasks use `profiles/native115/`. Pending artifact hashes fail before +runtime creation. All profiles remain UNQUALIFIED until their exact packaged +candidate completes the gates. Client-resource probes are outside this port. + +## Campaign Records + +The initial 2026-10-26 harness A `test assemble verifyHarnessIsolation` build +passed 52 Java/runner tests with no failures/errors/skips. Runtime evidence is +candidate-specific; later harvest probes and production replacements do not +inherit an A pass. Profiles and input manifests are parent-owned. + +- [Disposable114 source readiness](build/native115-disposable114-b/source-readiness.json): + all 14 frozen and ten connected inputs verified, including six new stopped + native114 source receipts. This is source approval, not a native115 result. +- [Connected A campaign](build/native115-connected-20261026-a/native115-campaign-results.json): + 10/10 PASS, all initial/restart preservation comparisons, 512+512 measured ticks, + continued output and unchanged full source fingerprints. The + [final verification log](build/native115-connected-20261026-a-final-verification.log) + rechecks all 70 protocol gates, actual tick counts and archived jars. +- [Endurance A corrected record](build/native115-endurance-20261026-a/native115-endurance-results.corrected-attribution.json): + SUPERSEDED_INCOMPLETE after 18/30 passing minute batches in each environment. + The parent coordinating assistant requested the stop for replacement + recipe-queue, creative-guard and Steam-track-property fixes; the human user + did not request it. This is neither a machine failure nor an endurance PASS. + The 1800-second threshold is unchanged; restart/resume and the full raw audit + were not reached. The [attribution erratum](build/native115-endurance-20261026-a/stop-attribution-erratum.json) + retains hashes of the unmodified original records. + +Connected A and the incomplete endurance run use archived harness release +`7DAE086A7A9ADC879D0990BEB033690E90F599F2B668A3566DCB53CD453A2547` and +[Manifest A](build/native115-inputs-20261026-current-archived.json), SHA-256 +`5608065DAA5C45309C9A38D5E420A0E2EFCF2496E6D9F00DA7B5B1836EFDCBA7`. +The original endurance interruption ended the process tree before the +controller's finally block, so its original shutdown flush is unverified. +Separate save-only recovery boots subsequently flushed and stopped both +disposable worlds; those logs do not prove the interrupted shutdown or +endurance restart/resume. See the [termination receipt](build/native115-endurance-20261026-a/superseded-termination.json) +with the linked attribution correction above. No A runtime remains. + +These are historical A results, not qualification of the replacement candidate. +See the parent's [current qualification ledger](../docs/1.15/CURRENT-QUALIFICATION.md) +for overall status; manual/client acceptance remains separate. + +## Candidate D Records + +[Immutable D inputs](build/native115-inputs-20261026-final-d-archived.json) use +manifest SHA-256 `3605B9BBF2B5C94C8278B31B5984969DD55B8F277E3B994496F045FA7CA79BE8` +and harness C `B8F733F37B51A2EE8A0C11F21C1C84336FD8FCCDE998611DA334021C29CC34FA`. +The approved 14/10 source routes remain SHA-256 +`A5241F5F41426DC57DD85A15B62B211415BE8B69D004154F8FC47DDF239B35C2`. + +- [Frozen D](build/native115-frozen-20261026-d/native115-campaign-results.json): + 14/14 PASS. Its [independent raw audit](build/native115-frozen-20261026-d/independent-raw-audit.json) + recomputes conversion, restart and idempotence from source/destination snapshots, + verifies source approvals, jar pins and clean native boot logs, and records full + source fingerprints before/after the audit. +- [Connected D](build/native115-connected-20261026-d/native115-campaign-results.json): + 10/10 PASS, including all five native114 sources. Its + [independent raw audit](build/native115-connected-20261026-d/independent-raw-audit.json) + replays preservation, 512+512 tick operation, object identity/conservation and + continued output across restart. Original whole-source fingerprints match; + no construction/refill authority was present. +- [Endurance D](build/native115-endurance-20261026-d/native115-endurance-results.json): + overall FAIL, retained unchanged. Both physical environments completed 30 batches + over 1801.578 seconds, with 36536/36033 game ticks, clean restart and a resumed + minute, but the strict log gate found a vanilla POI ERROR during fresh setup. + The [standard independent audit log](build/native115-endurance-20261026-d-independent-audit.log) + correctly refuses certification. The separate + [raw diagnostic replay](build/native115-endurance-20261026-d/raw-diagnostic-replay-NOT-QUALIFICATION.json) + reconstructs 33 checkpoints per world but is explicitly NOT an endurance PASS. + +The setup ERROR is a generated village `minecraft:composter[level=0]` / farmer +POI at `(-126,5,27)`, cleared to AIR on the W-03 boundary. Saved village/template +metadata and a native before-build POI record identify the block and custody. +The [bounded same-seed setup repro](build/native115-poi-setup-repro-d/poi-setup-results.json) +reproduces first-build ERROR twice with structures enabled; repeat builds are +clean. Preload/drain and two NEW structures-disabled worlds pass the same strict +log gate. See [the diagnosis](build/native115-poi-diagnostic-d/DIAGNOSIS.md). +No ERROR filter, oracle, production jar, harness Java or imported source was changed. +Any approved fresh-fixture isolation fix requires a new full endurance run; +the failed D evidence must not be relabelled. + +### Fresh Endurance Fixture Isolation + +The authorized fixture-only adapter change rejects an existing world before +writing settings. Only a NEW fresh115 world receives `generate-structures=false` +and the original failed world's seed `5919679243107101036` before creation. +The imported world's native generation settings are read and compared, never +rewritten. Native POI handling, original machine oracles, 60-second watchdog, +300-second startup budget, strict ERROR gate, 30 minute batches, tick minimum, +restart and resumed minute remain unchanged. + +All [14 focused guard tests](build/native115-endurance-isolation-focused-tests.log) +pass. The [bounded native adapter proof](build/native115-poi-isolated-adapter-d/fixture-isolation-results.json) +ran at 14:08:02--14:08:17 UTC on 2026-10-26 and saved/stopped cleanly. Before +any Works build, archived raw WorldInfo has `MapFeatures=0`, the exact original +seed and `generatorName=flat`; the native snapshot has AIR at `(-126,5,27)`. +Both subsequent setup builds pass the unchanged strict log gate. Its +[independent raw fixture audit](build/native115-poi-isolated-adapter-d/independent-fixture-audit.json) +also verifies the original D FAIL, five same-seed red/preload/structures-off +cases, complete approved native114 source fingerprints and all 114 protected +historical file hashes. This is NOT an endurance qualification. + +The coordinating parent requested a hold before the new 1800-second run could +start because of the separately identified Base Metals persistence race. +`native115-endurance-20261026-d-isolated` was never started or created; no +timed phase was interrupted and no live inputs were swapped. That hold ended +only after the parent supplied immutable candidate F below; no D result +transfers to F. + +## Candidate F Endurance + +The [new F endurance run](build/native115-endurance-20261026-f-isolated/native115-endurance-results.json) +is PASS for this automated endurance scope. Both worlds entered the unchanged +1800-second measured protocol at `2026-10-26T14:21:58.222497+00:00`, on ports +25590/25591; the controller completed at `2026-10-26T14:53:34.371139+00:00`. +Both completed 1801.578 measured seconds, 30 minute batches, save/restart and +the full resumed minute, with 33 verified ledger checkpoints per environment. +Fresh/copy tick advances are 36617/35992, independently recomputed from raw +setup and batch-30 checkpoints and above the unchanged 32400-tick minimum. + +The [independent raw audit](build/native115-endurance-20261026-f-isolated/independent-raw-audit.json) +replays all 33 ledgers and 528 raw station snapshots per world: 66 ledgers and +1056 snapshots total, using the original conservation algorithms. It also +checks exact inventory-command acknowledgements, 13179 before restart and +439 after restart per world. Strict native log checks PASS across all five +boot logs; no ERROR waiver or POI filter was added. Both servers saved and +stopped through the controller with exit code zero, and both leases were free +after completion. No interruption or human stop is attributed to this run. + +[Launch/completion provenance](build/native115-endurance-20261026-f-isolated-provenance.json) +records the complete approved source runtime fingerprint, jar identities, +controller/helper hashes, arguments, Java 8 executable and runtime seed files. +The [additional independent provenance audit](build/native115-endurance-20261026-f-isolated/final-provenance-audit.json) +PASS rechecks those hashes, the approved native114 source receipt, actual +saved WorldInfo, clean saved shutdowns and all 114 protected historical files. +The complete source runtime, server seed, F manifest and protocol sources +are unchanged before/after. The original D FAIL is retained with its hash; +this new F PASS does not relabel D or qualify unrelated suites/manual checks. + +[Immutable F inputs](build/native115-inputs-20261026-final-f-archived.json) have +SHA-256 `A9BCD5A442F6332010F9D467BD00A685AA953339C39545812B32B825B96E64AA`. +They use corrected Base Metals 3.0.2.115021 source +`ac1781e7cade15045ffe0135077087b4d1722603`, release +`3F50AFBEF17800ECADE42A69B81E541E9FA8C6C4C392ED4DD0FE5A66DC9EFF07`, and +harness E `C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B`. +The three Advantage release jars retain D's exact bytes, but F results are +independent of D. No inputs are repinned or swapped during execution. + +Before construction, [F's native fresh-world proof](build/native115-endurance-20261026-f-isolated/fresh115/fresh-fixture-proof.json) +records actual `MapFeatures=0`, original seed `5919679243107101036`, and AIR at +`(-126,5,27)`. The comprehensive native114 copied source instead retains +`MapFeatures=1`, its original seed `8795347008554539309`, and +[frozen import preservation](build/native115-endurance-20261026-f-isolated/original110-chained113-native115/native115-import-comparison.json) +PASS before any disposable Works construction. Endurance measures rebuilt +Works, not the original imported machinery. The protected source world was +never launched or edited; full before/after fingerprints match. + +## Candidate F Migrations + +The [F frozen campaign](build/native115-frozen-20261026-f/native115-campaign-results.json) +and its [independent raw audit](build/native115-frozen-20261026-f/independent-raw-audit.json) +PASS all 14 routes: 13 historical direct/chained routes plus comprehensive +native114. The audit independently recomputes all 42 conversion, restart and +idempotence comparisons with unchanged oracles, checks all 28 native boot/save +logs, and hashes every staged F jar and launcher. Source manifest/snapshot +provenance matches the original A524 route file. All source worlds match their +approved hashes; complete source runtimes also match the prior D audit's source +fingerprints and the new audit's before/after hashes. This uses D only as a +source-fingerprint reference, never as an inherited F runtime verdict. + +All 114 protected historical files and every existing F campaign evidence file +are unchanged. No server, Gradle build, fixture rewrite or oracle edit was used +for this audit. Audit JSON SHA-256: +`EF0C37E1F1422452AFC5FFCC8FDFC4C68E4D7E3BFDA9F7124A56EBD781AB3226`. +The [read-only auditor](build/audit-migrations-f.py) is new ignored build evidence; +the original campaign report and immutable F/A524 inputs are untouched. + +The [F connected campaign](build/native115-connected-20261026-f/native115-campaign-results.json) +and its [independent raw audit](build/native115-connected-20261026-f/independent-raw-audit.json) +PASS all ten cases: five historical sources and all five disposable native114 +sources. The same unchanged auditor independently recomputes all 70 gates: +initial/restart preservation, exact 512+512 requested and advanced native ticks, +operating object identity/conservation, continued output and both native boot +proofs per case. All 20 boot/save logs and every staged F jar/launcher pass. +No build/refill authority was present. Source receipt/world hashes, complete +source fingerprints, all 114 protected historical files and existing campaign +evidence remain unchanged. This replay starts no server and runs no Gradle. +Connected audit JSON SHA-256: +`86B6E7BEADA8C766698EC8806551588E51EA6914B35DADAD1F125405B23B121C`. +F connected is independently verified, not inferred from frozen F or connected D. + +## Invariants + +- Historical profiles, every native114 Python script/test, original semantic + algorithms and both station source resources remain byte-identical. + `migration/native115-historical-inputs.json` checks 114 protected inputs. +- Frozen/connected destinations cannot build, reset or refill imported equipment. + Frozen preservation and connected operating results remain separate. +- Endurance first preserves the copied fixture, then measures rebuilt Works in + fresh and copied worlds using the retained feeding protocol. It does not prove + endurance of the original imported equipment. The copied A, D and F source is the + verified native114 comprehensive fixture despite the retained historical + runtime label `original110-chained113-native115`. +- Original fixtures are never launched or edited. Output paths must be new, + under `test-rig/build`, and disjoint from all protected inputs. +- Existing assertions, timings, quantities, normalizations and failure meanings + remain unchanged. Failed evidence is retained, never overwritten by reruns. +- A fresh fingerprint of an old native114 destination is not approval. + +## Engine Adapters + +Non-loading observations use `ServerChunkProvider.getChunkWithoutLoading`. +Lifecycle snapshots read actual FORCED tickets and native ChunkHolder/ChunkStatus +calculations without adding tickets or loading chunks. Exact Forge 31 sources +give forced/entity-ticking level 31, FULL 33, maximum holder 44, generation +distance 11 and holder radius 13. Live snapshots must agree before lifecycle +oracles run; load observations require a real forced ticket. The inherited +45-second unload budget is unchanged. + +Private NBT constructors use native value factories. Machine clones use +`setWorldAndPos`. Respawn uses the native four-argument spawn setter, disabling +only the optional chat notification; real death and native respawn handling +remain required. + +`native115_harvest.py` exercises actual survival +`PlayerInteractionManager.tryHarvestBlock` across two boots. The original fourteen cases +require wrong-tool/under-tier failures, sulfur pickaxe level 0 and 1..4 powder, +lithium level 1 and one ore, silk-touch ore identity, and normal/silk steel-frame +drops remain required. Another 256 cases mine wire/scaffold blocks by hand and +silk touch across every six-way connection mask, checking real collision shapes +and native solidness. The accepted114 baseline is wire=true, scaffold=false; +see [the harvest baseline](../docs/1.15/HARVEST-BASELINE.md). A separate live photovoltaic witness retains its +legacy solid=true property: 271 checks per boot. Loot JSON presence alone cannot +pass. Portable tank break/drop/replace, +retained NBT, capabilities and locked automation remain in the interop runner. + +## Native114 Source Generation + +`native115_campaign.py prepare` reads the exact accepted frozen-13 and +connected-5 records, original source manifests/checkpoints and approved hashes. +It creates a pending plan, not approval of old destination saves. + +`generate114` stages NEW disposable sources with accepted archived target114 +jars and copies only approved historical inputs. It reuses native114's strict +conversion/operation algorithms and records receipts after clean shutdown. +The comprehensive source must match all 355 blocks and 332 tiles. All five +connected cases retain conveyor/crusher, electric/steam lanes, both drills and +battery, preserve stock and operate for 512 ticks on each of two boots. +Receipts bind complete runtime/world hashes, jar manifest hashes and semantic +comparisons. No canonical process is launched. + +Only then can `run frozen` and `run connected` create 115 copies. Their required +matrix is 13 historical frozen routes plus comprehensive native114, and five +historical plus five new native114 connected sources. There is no reconstruction +or refill in 115. Actors, dimensions, player storage, respawn and transformer +ordering retain their independent original oracles. + +See [the command matrix](../docs/1.15/TEST-HARNESS.md) for exact proof paths, +remaining suites, concurrency and qualification status. diff --git a/test-rig/README.md b/test-rig/README.md index a3850c5..2a4d3b7 100644 --- a/test-rig/README.md +++ b/test-rig/README.md @@ -1,15 +1,17 @@ -# Advantage Works 1.14.4 Native Test Rig +# Advantage Works 1.15.2 Native Test Rig -The native target is Minecraft 1.14.4 / Forge 28.2.26, snapshot mappings -20190719-1.14.3, MCP 20190829.143755, Java 8 bytecode and harness -`1.1.0.114041`. Read [the native114 runner and qualification limits](NATIVE114.md) +The native target is Minecraft 1.15.2 / Forge 31.2.57, snapshot mappings +20200514-1.15.1, MCP 20200515.085601, Java 8 bytecode and harness +`1.1.0.115021`. Read [the native115 runner and qualification limits](NATIVE115.md) before using this checkout. Build success is not runtime or migration acceptance. -Native destination profiles live only in `profiles/native114/`. The original +Default destination profiles live only in `profiles/native115/`. The original profiles, station specification and legacy migration source adapters remain unchanged. New profiles explicitly reference the historical PA113 source root; -the native runner refuses to launch an old profile as a native114 destination. -Both Mineralogy profiles are fail-closed pending a verified native candidate. +the native runner refuses to launch an old profile as a native115 destination. +The native114 scripts/profiles and [their qualification record](NATIVE114.md) +remain historical evidence. Missing 115 artifact pins fail before runtime +creation. No 114 pass is a 115 runtime qualification. ## Retained 1.13.2 Protocol Reference diff --git a/test-rig/build.gradle b/test-rig/build.gradle index 962f3c7..96bb229 100644 --- a/test-rig/build.gradle +++ b/test-rig/build.gradle @@ -51,14 +51,39 @@ repositories { } } +def powerCompileJar = file(project.findProperty('powerAdvantageDeobfJar') + ?: '../build/libs-dev/PowerAdvantage-2.4.6.115021-deobf.jar') + dependencies { implementation minecraft.dependency( "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") runnerImplementation 'com.google.code.gson:gson:2.8.0' testImplementation sourceSets.runner.output testImplementation 'junit:junit:4.13.2' - compileOnly files(project.findProperty('powerAdvantageDeobfJar') - ?: '../build/libs-dev/PowerAdvantage-2.4.5.114041-deobf.jar') + compileOnly files(powerCompileJar) +} + +tasks.register('verifyWorksCompileInputPin') { + group = 'verification' + description = 'Checks the exact parent-supplied Power API artifact before harness compilation.' + inputs.file(powerCompileJar) + inputs.property('sha256', project.poweradvantage_deobf_sha256) + doLast { + if (!powerCompileJar.isFile()) throw new GradleException("Missing native115 Power compile input: ${powerCompileJar}") + String actual = java.security.MessageDigest.getInstance('SHA-256') + .digest(powerCompileJar.bytes).encodeHex().toString().toUpperCase(Locale.ROOT) + if (actual != project.poweradvantage_deobf_sha256) { + throw new GradleException("Native115 Power compile input changed: ${actual}; obtain a new parent pin") + } + } +} + +tasks.named('compileJava') { dependsOn tasks.named('verifyWorksCompileInputPin') } + +tasks.named('test', Test) { + def temp = layout.buildDirectory.dir('test-temp') + doFirst { temp.get().asFile.mkdirs() } + systemProperty 'java.io.tmpdir', temp.get().asFile.absolutePath } tasks.named('processResources', ProcessResources) { @@ -114,7 +139,7 @@ def registerWorksRunner = { String taskName, String profileName, boolean automat classpath = sourceSets.runner.runtimeClasspath mainClass = 'zone.moddev.mc.advantageworks.runner.WorksRunner' if (!automated) standardInput = System.in - args '--profile', file("profiles/native114/${profileName}.json").absolutePath + args '--profile', file("profiles/native115/${profileName}.json").absolutePath if (automated) args '--automated' if (project.hasProperty('worksStations')) { args '--stations', project.property('worksStations').toString() @@ -129,7 +154,7 @@ def registerWorksRunner = { String taskName, String profileName, boolean automat } registerWorksRunner('runWorksDev', 'dev-core', false, - 'Builds and launches a disposable interactive 1.14.4 Advantage Works development server.') + 'Builds and launches a disposable interactive 1.15.2 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, @@ -153,13 +178,13 @@ registerWorksRunner('verifyWorksWorldgenLegacyImport', 'worldgen-legacy-import', registerWorksRunner('runWorksPackagedCurrent', 'packaged-current', false, 'Launches the complete packaged works on localhost for manual inspection.') -tasks.register('verifyWorksNative114Inputs', JavaExec) { +tasks.register('verifyWorksNative115Inputs', JavaExec) { group = 'verification' - description = 'Checks native114 candidate identities and hashes without creating or opening a world.' + description = 'Checks native115 candidate identities and hashes without creating or opening a world.' dependsOn tasks.named('runnerClasses'), tasks.named('jar'), releaseJar classpath = sourceSets.runner.runtimeClasspath mainClass = 'zone.moddev.mc.advantageworks.runner.WorksRunner' - args '--profile', file("profiles/native114/${project.findProperty('worksProfile') ?: 'packaged-current'}.json").absolutePath, + args '--profile', file("profiles/native115/${project.findProperty('worksProfile') ?: 'packaged-current'}.json").absolutePath, '--validate-only' } @@ -168,7 +193,7 @@ tasks.register('verifyHarnessIsolation') { description = 'Confirms the PowerAdvantage release contains no test-harness content.' doLast { File powerJar = file(project.findProperty('powerAdvantageReleaseJar') - ?: '../build/libs/PowerAdvantage-2.4.5.114041.jar') + ?: '../build/libs/PowerAdvantage-2.4.6.115021.jar') if (!powerJar.isFile()) { throw new GradleException("Build PowerAdvantage first: ${powerJar}") } diff --git a/test-rig/client-resources/README.md b/test-rig/client-resources/README.md index ec34c09..d1bdc7d 100644 --- a/test-rig/client-resources/README.md +++ b/test-rig/client-resources/README.md @@ -1,13 +1,20 @@ # Native Client Resource Check -This build-only Minecraft 1.14.4 / Forge 28.2.26 probe checks PowerAdvantage, or all three Advantage mods in the combined profile, after Minecraft bakes its models and stitches its textures. It compiles against the actual mapped snapshot `20190719-1.14.3` jar. It registers no gameplay content, opens no world, and shuts down its own disposable client when finished. It is not included in release jars or ordinary Eclipse launches. +This build-only Minecraft 1.15.2 / Forge 31.2.57 probe checks PowerAdvantage, or all three Advantage mods in the combined profile, after Minecraft bakes its models and stitches its textures. It targets the actual mapped snapshot `20200514-1.15.1` jar with MCP `20200515.085601`. It registers no gameplay content, opens no world, and shuts down its own disposable client when finished. It is not included in release jars or ordinary Eclipse launches. -For native recipe-book grouping, supply a **new native 1.14.4 server export** +Compilation and the full combined native graphical run passed on 2026-10-26, +including fresh 1.15 recipe fixtures and all 25 worldless renderer-geometry cases. +Hydroturbine orientation/spin and all 1.15 manual acceptance remain pending. +See [the native 1.15 checks and evidence](../../docs/1.15/CLIENT-RESOURCES.md). +Use matching native jars/hashes. Run Gradle only when the shared cache is free +of other builds; start the client only when authorized. + +For native recipe-book grouping, supply a **new native 1.15.2 server export** containing `recipes-{normal,tech,apocalyptic}-sweep.bin`, their matching successful JSON reports with `bookRecipes`, and `AdvantageWorksMigration/level.dat`. The copied convenience `migration/recipe_unlocks.py` still targets 1.13.2; do -not use its outputs or historical qualification as 1.14.4 evidence. Native -fixture generation is owned by the server harness, outside this directory. +not use its outputs or historical qualification as 1.15.2 evidence. Native +fixture generation uses the separate server harness. The separate native campaign retains the five unlock/persistence boots and all 18 progression checks. It reads recipes and advancements from the packaged candidate jars, validates target hashes, creates new loopback runtimes only, @@ -16,7 +23,7 @@ existing Python 3.11+ environment with `migration/requirements.txt`, plus Java 8 it does not invoke Gradle: ```powershell - -B test-rig/migration/native114_recipes.py --target-manifest test-rig/build/native114-inputs-20261023-b.json --java8 '/bin/java.exe' --port 25577 --output test-rig/build/native114-recipes-new + -B test-rig/migration/native115_recipes.py --target-manifest '' --java8 '/bin/java.exe' --port 25577 --output test-rig/build/native115-recipes-new ``` `--campaign unlock` or `--campaign progression` selects a single campaign. @@ -29,14 +36,14 @@ saved book to avoid that persistence check. After native exports are available, pass the campaign's `unlock` subdirectory: ```powershell -./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -RecipeBookFixtures '' +./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -RecipeBookFixtures '/unlock' ``` The server exports its actual recipe packets and matching metadata. The client imports the generated world's Forge registry snapshot before decoding those packets, reproducing the item-ID synchronization normally supplied by Forge's -connection handshake. The saved header must identify Minecraft 1.14.4 and -Forge 28.2.26; a legacy fixture is rejected. It then rebuilds native +connection handshake. The saved header must identify Minecraft 1.15.2 and +Forge 31.2.57; a legacy fixture is rejected. It then rebuilds native `ClientRecipeBook`/`RecipeList` cells for Normal, Tech and Apocalyptic modes: each cell must contain exactly one output identity, all loaded variants must remain accessible, and same-output @@ -47,8 +54,15 @@ The probe reads only the disposable fixture's saved registry data; it never opens that world or a user's world in Minecraft. This is a native cell-model and button-rendering check, not manual navigation or notification acceptance. -Obtain the shared `D:/GradleCaches/minecraft-mods-shared` slot before any Gradle -command. Use the repository's Gradle 9.6.1 / ForgeGradle 7 setup with Java 17 +The static checks do not require artifacts, Gradle, Java or a graphical client: + +```powershell +./test-rig/client-resources/test-launcher.ps1 -StaticOnly +./test-rig/client-resources/test-probe-contract.ps1 +``` + +Before running Gradle, check that the shared `D:/GradleCaches/minecraft-mods-shared` +cache is free of other builds. Use the repository's Gradle 9.6.1 / ForgeGradle 7 setup with Java 17 for Gradle and the configured Java 8 compilation toolchain. Supply Java 8 to the disposable launcher. Do not launch the actual client until authorized: @@ -56,16 +70,16 @@ the disposable launcher. Do not launch the actual client until authorized: ./gradlew.bat check clientResourceProbeJar verifyEclipseProductionClasspath ./gradlew.bat prepareCombinedEclipseLaunch ./test-rig/client-resources/test-launcher.ps1 -./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -PreflightOnly +./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -RecipeBookFixtures '/unlock' -PreflightOnly # Only after authorization for the graphical run: -./test-rig/client-resources/run.ps1 -JavaHome '' +./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -RecipeBookFixtures '/unlock' ``` Each run gets a new directory under `build/client-resource-runs/`. The runner snapshots production classes/resources, uses the audited native launch dependencies, and loads only the probe as an additional mod. Helpers launch hidden, with separate stdout/stderr logs and a bounded timeout. It retains logs, images and `client-resources.json`; missing reports, failed assertions, and model/texture errors fail the run. A runner failure gets a separate `runner-failure.json`, without replacing partial native results. Existing saves, configuration, and prior reports are untouched. `-PreflightOnly` creates an input snapshot without starting Java or claiming a graphical pass. -The default `-OutputSource Gradle` merges Gradle's classes and processed resources. To test the actual Eclipse output instead, pass `-OutputSource Eclipse`; this copies `bin/main` verbatim and does not overlay Gradle resources. `production-files.json` hashes every selected class/resource. `client-input.json` records that manifest's hash, the Java binary, probe, launch, production metadata, every dependency archive, expansion candidate hashes and optional fixture hashes. The current Power deobf candidate is recorded separately, defaulting to `build/libs-dev/PowerAdvantage-2.4.5.114041-deobf.jar`; `-PowerCandidate` selects an explicit native candidate. That jar hash is provenance, not a claim that Eclipse/Gradle output equals the jar. Missing/unexpanded metadata or a legacy target fails preflight. The native result records actual discovered mod versions and the exact game/Forge target. Refresh candidate hashes after any main-owned rebuild before final evidence is taken. The [historical Eclipse metadata regression](../../docs/1.13/ECLIPSE-METADATA.md) explains the original failure, not native 1.14 acceptance. +The default `-OutputSource Gradle` merges Gradle's classes and processed resources. To test the actual Eclipse output instead, pass `-OutputSource Eclipse`; this copies `bin/main` verbatim and does not overlay Gradle resources. `production-files.json` hashes every selected class/resource. `client-input.json` records that manifest's hash, the Java binary, probe, launch, production metadata, every dependency archive, expansion candidate hashes and optional fixture hashes. The current Power deobf candidate is recorded separately, defaulting to `build/libs-dev/PowerAdvantage-2.4.6.115021-deobf.jar`; `-PowerCandidate` selects an explicit native candidate. That jar hash is provenance, not a claim that Eclipse/Gradle output equals the jar. Missing/unexpanded metadata or a legacy target fails preflight. The native result records actual discovered mod versions and the exact game/Forge target. Refresh candidate hashes after any production rebuild before final evidence is taken. The [historical Eclipse metadata regression](../../docs/1.13/ECLIPSE-METADATA.md) explains the original failure, not native 1.15 acceptance. -For coordinated startup, first run `gradlew.bat prepareCombinedEclipseLaunch`, then add `-LaunchProfile runClientAllAdvantage.launch -RequireExtensions` to the probe command. The report's `advantage_mods` must list all three mods. With this flag, resource and item-name assertions cover Power, Steam and Electric, and model/blockstate errors from any of their namespaces fail the run. Without it, the original Power-only scope is unchanged. Main must pin every rebuilt expansion jar in the native114 profiles before regenerating the combined launch. Recipe grouping requires that combined scope; without fixtures it explicitly reports `NOT_RUN_NO_NATIVE_FIXTURES`. +For the combined run, use `gradlew.bat prepareCombinedEclipseLaunch`, then add `-LaunchProfile runClientAllAdvantage.launch -RequireExtensions` to the probe command. The report's `advantage_mods` must list all three mods. With this flag, resource and item-name assertions cover Power, Steam and Electric, and model/blockstate errors from any of their namespaces fail the run. Without it, the original Power-only scope is unchanged. Power must be `2.4.6.115021`; Steam and Electric must each be `2.2.7.115021`. Pin every rebuilt expansion jar in the native115 profiles before regenerating the combined launch. Recipe grouping requires that combined scope; without fixtures it explicitly reports `NOT_RUN_NO_NATIVE_FIXTURES`, not a full combined resource/recipe pass. Assertions cover every registered item and valid block state in the selected Advantage namespaces, nonempty item/machine geometry, missing-model and missing-texture sprites, English item names, the shared creative tab and its icon, and agreement between Power's modern block and legacy GUI translation keys. Native fluid block models are intentionally particle-only. This test does not certify machine operation, animated entity rendering, or third-party resource packs. @@ -73,9 +87,16 @@ The `ClientResourceContractTest` checks cover literal model paths, removed legac Tank visual regressions use Minecraft's actual neighbour-face culling beneath both tanks, native Forge fluid capability simulation/execution, full 10,000 mB and empty tank update packets, and a native registered `ContainerType` screen initialized without a player/world. The actual filtered-tank gauge and frame render into an OpenGL framebuffer through the production GUI. The pixel at `(88,39)` must remain blue with the original thresholds (`blue > red + 20`, `blue > green + 10`); `tank-water-gauge.png` is written before assertions. Historical hidden-floor/untinted `ADADAD` observations remain 1.13 evidence only. No test-world loading or fluid-network changes are required. -Oil regressions verify both buckets through native `BucketItem.getFluid()` and Forge's fluid-item capability, then call the initialized native `BlockRendererDispatcher` / `FluidBlockRenderer` against a read-only synthetic `IEnviromentBlockReader`. Still and flowing crude/refined oil must emit vertices using their own atlas sprites and `FFFFFF` top-face tint. Vanilla water and lava remain controls. Forge 28 reads sprites/tint from `FluidAttributes`; no removed oil transformer or 1.13 hook is assumed. The probe temporarily supplies native vanilla water/lava tags at the main menu and restores them afterward; no production tags or saves are written. `fluid_rendering` retains case results. The historical water-sprite/`3F76E4` failure is not native 1.14 evidence. +Oil regressions verify both buckets through native `BucketItem.getFluid()` and Forge's fluid-item capability, then call the initialized native `BlockRendererDispatcher` / `FluidBlockRenderer` against a read-only synthetic `ILightReader`. Still and flowing crude/refined oil must emit vertices using their own atlas sprites and `FFFFFF` top-face tint. Vanilla water and lava remain controls. Forge 31 reads sprites/tint from `FluidAttributes`; no removed oil transformer or 1.13 hook is assumed. Completed vertices are inspected through `BufferBuilder.getNextBuffer()` and the actual native format element offsets; atlas lookup uses `getAtlasSpriteGetter(AtlasTexture.LOCATION_BLOCKS_TEXTURE)`. The probe temporarily supplies native vanilla water/lava tags at the main menu and restores them afterward; no production tags or saves are written. `fluid_rendering` retains case results. The historical water-sprite/`3F76E4` failure is not native 1.15 evidence. The native recipe image still requires exactly thirteen separate cells and more than 1,000 colored framebuffer pixels. A blank image is retained before failure. Compilation and input preflight are not graphical acceptance, recipe discovery acceptance, manual GUI navigation or server-to-client menu-handshake acceptance. + +The optional `-ChildRendererGeometry` combined-run switch adds actual registered +Steam drill-bit and Electric beam/turret buffer checks after these primary +oracles. It does not alter their capture logic or thresholds. Hydroturbine +registration/texture are checked, but orientation/spin geometry remains an +explicit disposable-world gap. See [Additional Renderer Geometry](../../docs/1.15/CLIENT-RESOURCES.md#additional-renderer-geometry) +for scope, the static wiring test and the completed native run evidence. 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 new file mode 100644 index 0000000..6ebf063 --- /dev/null +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ChildRendererGeometryProbe.java @@ -0,0 +1,412 @@ +package zone.moddev.mc.advantageworks.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.mojang.blaze3d.matrix.MatrixStack; +import com.mojang.blaze3d.vertex.IVertexBuilder; +import com.mojang.datafixers.util.Pair; +import cyano.poweradvantage.api.PoweredEntity; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.BufferBuilder; +import net.minecraft.client.renderer.IRenderTypeBuffer; +import net.minecraft.client.renderer.RenderState; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.tileentity.TileEntityRenderer; +import net.minecraft.client.renderer.tileentity.TileEntityRendererDispatcher; +import net.minecraft.client.renderer.vertex.DefaultVertexFormats; +import net.minecraft.client.renderer.vertex.VertexFormat; +import net.minecraft.client.renderer.vertex.VertexFormatElement; +import net.minecraft.entity.EntityType; +import net.minecraft.resources.IResource; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.tileentity.TileEntityType; +import net.minecraft.util.Direction; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.registries.ForgeRegistries; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +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.steamadvantage.graphics.DrillBitRenderer"; + private static final String DRILL_RENDERER = "zone.moddev.mc.electricadvantage.graphics.LaserDrillRenderer"; + private static final String TURRET_RENDERER = "zone.moddev.mc.electricadvantage.graphics.LaserTurretRenderer"; + private static final String TURBINE_RENDERER = "zone.moddev.mc.electricadvantage.graphics.HydroturbineRenderer"; + + static void verify(Minecraft minecraft, JsonObject result) { + JsonObject report = new JsonObject(); + JsonArray cases = new JsonArray(); + report.add("cases", cases); + report.addProperty("scope", "REGISTERED_RENDERER_CPU_GEOMETRY_NOT_PIXEL_ACCEPTANCE"); + result.add("child_renderer_geometry", report); + try { + require(Boolean.getBoolean("advantageworks.requireExtensions"), "Child renderer geometry requires the combined profile"); + require(minecraft.world == null && minecraft.player == null && !minecraft.isIntegratedServerRunning(), "Geometry probe must remain worldless"); + steam(minecraft, cases); + drill(minecraft, cases); + turret(minecraft, cases); + run(cases, "hydroturbine-registration", row -> { + EntityType type = ForgeRegistries.ENTITIES.getValue(new ResourceLocation("electricadvantage:hydroturbine")); + require(type != null, "Missing registered hydroturbine type"); + EntityRenderer renderer = minecraft.getRenderManager().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(new ResourceLocation("electricadvantage: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."); + }); + boolean failed = false; + int passed = 0, gaps = 0; + for (com.google.gson.JsonElement entry : cases) { + String status = entry.getAsJsonObject().get("status").getAsString(); + failed |= "FAIL".equals(status); + if ("PASS".equals(status)) passed++; + if ("MANUAL_GAP_WORLD_REQUIRED".equals(status)) gaps++; + } + report.addProperty("passed_geometry_cases", passed); + report.addProperty("manual_gaps", gaps); + require(cases.size() == 26, "Incomplete bounded child renderer case inventory"); + require(!failed, "Registered child renderer geometry failed; inspect child-renderer-geometry.json"); + require(passed == 25 && gaps == 1, "Child renderer cases were skipped or misclassified"); + report.addProperty("status", "PASS_WITH_MANUAL_GAPS"); + result.addProperty("child_renderer_geometry_status", "PASS_WITH_MANUAL_GAPS"); + } catch (RuntimeException | LinkageError | AssertionError failure) { + report.addProperty("status", "FAIL"); + report.addProperty("error", failure.toString()); + result.addProperty("child_renderer_geometry_status", "FAIL"); + throw failure; + } finally { + try { Files.write(Paths.get("child-renderer-geometry.json"), report.toString().getBytes(StandardCharsets.UTF_8)); } + catch (IOException failure) { throw new IllegalStateException("Could not retain child renderer geometry evidence", failure); } + } + } + + private static void steam(Minecraft minecraft, JsonArray cases) { + for (Direction facing : Direction.values()) { + final List> frames = new ArrayList<>(); + for (float partial : new float[]{0F, 0.5F}) run(cases, "steam-drillbit-" + facing + "-" + partial, row -> { + TileEntity tile = tile("steamadvantage:drillbit"); + tile.getClass().getMethod("setDirection", Direction.class).invoke(tile, facing); + set(tile, "rotation", 0F); + List vertices = render(minecraft, tile, STEAM_RENDERER, partial, + "steamadvantage:textures/entity/drill_bit.png", row); + mesh(vertices, 24); + int axis = facing.getAxis() == Direction.Axis.X ? 0 : facing.getAxis() == Direction.Axis.Y ? 1 : 2; + for (float[] vertex : vertices) for (int i = 0; i < 3; i++) require(vertex[i] >= -EPSILON && vertex[i] <= 1 + EPSILON, "Drill bit escaped its block"); + require(close(min(vertices, axis), 0) && close(max(vertices, axis), 1), "Drill bit is not aligned to " + facing.getAxis()); + for (int i = 0; i < 3; i++) if (i != axis) require(max(vertices, i) - min(vertices, i) < 0.72F, "Drill bit cross-section exceeds its rotated radius"); + frames.add(vertices); + if (partial > 0) require(frames.size() == 2 && different(frames.get(0), vertices), "Steam partial-tick rotation did not move vertices"); + }); + } + } + + private static void drill(Minecraft minecraft, JsonArray cases) { + for (Direction facing : Direction.values()) run(cases, "electric-drill-" + facing, row -> { + TileEntity tile = tile("electricadvantage:electric_drill"); + // These are the production tile's synchronized renderer inputs, not replacement methods. + set(tile, "facingCache", facing); + set(tile, "laserLength", 4); + require(tile.getClass().getMethod("getFacing").invoke(tile) == facing, "Drill fixture facing was not consumed"); + require(((Number) tile.getClass().getMethod("getDrillLength").invoke(tile)).intValue() == 4, "Drill fixture length was not consumed"); + List vertices = render(minecraft, tile, DRILL_RENDERER, 0.5F, + "electricadvantage:textures/materials/laser_beam.png", row); + mesh(vertices, 16); + beam(vertices, facing, new float[]{0.5F, 0.5F, 0.5F}, 4F); + for (float[] vertex : vertices) require(close(vertex[3], 0) && close(vertex[4], 1), "Drill beam UV layout changed"); + }); + } + + private static void turret(Minecraft minecraft, JsonArray cases) { + run(cases, "turret-idle", row -> { + TileEntity tile = tile("electricadvantage:laser_turret"); + set(tile, "rotYaw", -90F); + set(tile, "rotOldYaw", -90F); + List vertices = render(minecraft, tile, TURRET_RENDERER, 0.5F, + "electricadvantage:textures/entity/laser_turret.png", row); + mesh(vertices, 24); + require(close(min(vertices, 0), 0.25F) && close(max(vertices, 0), 0.75F) + && close(min(vertices, 1), 0.5F) && close(max(vertices, 1), 1F) + && close(min(vertices, 2), 0.25F) && close(max(vertices, 2), 0.75F), "Idle turret body bounds changed"); + for (int i = 0; i < 4; i++) require(vertices.get(i)[4] >= 0.5F - EPSILON, "Idle turret uses the active optic UVs"); + }); + for (Direction facing : Direction.values()) run(cases, "turret-target-" + facing, row -> { + TileEntity tile = tile("electricadvantage:laser_turret"); + PoweredEntity machine = (PoweredEntity) tile; + machine.setEnergy(2000F, machine.getTypes()[0]); + float yaw = facing == Direction.EAST ? 0F : facing == Direction.SOUTH ? 90F : facing == Direction.WEST ? 180F : -90F; + float pitch = facing == Direction.UP ? 90F : facing == Direction.DOWN ? -90F : 0F; + set(tile, "rotYaw", yaw); + set(tile, "rotOldYaw", yaw); + set(tile, "rotPitch", pitch); + set(tile, "rotOldPitch", pitch); + set(tile, "targetLocked", true); + set(tile, "laserAttack", 5); + set(tile, "laserBlastLength", 4D); + require(Boolean.TRUE.equals(tile.getClass().getMethod("isActive").invoke(tile)) + && Boolean.TRUE.equals(tile.getClass().getMethod("showLaserLine").invoke(tile)), "Turret fixture did not enable the production target branch"); + List vertices = render(minecraft, tile, TURRET_RENDERER, 0.5F, + "electricadvantage:textures/entity/laser_turret.png", row); + mesh(vertices, 40); + beam(vertices.subList(24, 40), facing, new float[]{0.5F, 0.75F, 0.5F}, 4F); + for (float[] vertex : vertices.subList(0, 24)) for (int i = 0; i < 3; i++) + require(Math.abs(vertex[i] - (i == 1 ? 0.75F : 0.5F)) <= 0.434F, "Turret body escaped its rotation bounds"); + for (int i = 0; i < 4; i++) require(vertices.get(i)[4] <= 0.5F + EPSILON, "Active turret uses the idle optic UVs"); + }); + } + + private static TileEntity tile(String id) { + TileEntityType type = ForgeRegistries.TILE_ENTITIES.getValue(new ResourceLocation(id)); + require(type != null, "Missing registered tile type " + id); + TileEntity tile = type.create(); + require(tile != null && tile.getWorld() == null, "Tile factory must create a worldless native fixture"); + return tile; + } + + private static List render(Minecraft minecraft, TileEntity tile, String expectedRenderer, + float partial, String expectedTexture, JsonObject row) throws Exception { + TileEntityRenderer renderer = TileEntityRendererDispatcher.instance.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); + MatrixStack matrices = new MatrixStack(); + MatrixStack.Entry original = matrices.getLast(); + net.minecraft.client.renderer.Matrix4f position = original.getMatrix().copy(); + net.minecraft.client.renderer.Matrix3f normal = original.getNormal().copy(); + Capture buffers = new Capture(minecraft, new ResourceLocation(expectedTexture), row); + boolean balanced; + Throwable renderFailure = null; + try { + renderer.render(tile, partial, matrices, buffers, LIGHT, OverlayTexture.NO_OVERLAY); + } catch (RuntimeException | LinkageError | AssertionError failure) { + renderFailure = failure; + row.addProperty("render_error", failure.toString()); + throw failure; + } finally { + balanced = matrices.clear() && matrices.getLast() == original + && position.equals(original.getMatrix()) && normal.equals(original.getNormal()); + row.addProperty("matrix_balanced", balanced); + try { buffers.finish(); } + catch (Exception failure) { + if (renderFailure == null) throw failure; + renderFailure.addSuppressed(failure); + row.addProperty("capture_error", failure.toString()); + } + } + require(balanced, "Production renderer did not restore its matrix stack"); + row.add("bounds_xyz", ranges(buffers.vertices, 0, 3)); + row.add("uv_bounds", ranges(buffers.vertices, 3, 2)); + return buffers.vertices; + } + + private static final class Capture implements IRenderTypeBuffer { + private final Minecraft minecraft; + private final ResourceLocation expectedTexture; + private final JsonArray batches = new JsonArray(); + private final Map buffers = new LinkedHashMap<>(); + private final List vertices = new ArrayList<>(); + + Capture(Minecraft minecraft, ResourceLocation expectedTexture, JsonObject row) { + this.minecraft = minecraft; + this.expectedTexture = expectedTexture; + row.add("native_buffers", batches); + } + + @Override public IVertexBuilder getBuffer(RenderType type) { + require(type.getDrawMode() == 7 && type.getVertexFormat().equals(DefaultVertexFormats.ENTITY), "Unexpected production renderer vertex format/mode"); + BufferBuilder buffer = buffers.get(type); + if (buffer == null) { + require(buffers.size() < 8, "Renderer requested too many buffers"); + buffer = new BufferBuilder(4096); + buffer.begin(type.getDrawMode(), type.getVertexFormat()); + buffers.put(type, buffer); + } + return buffer; + } + + void finish() throws Exception { + for (Map.Entry entry : buffers.entrySet()) { + BufferBuilder buffer = entry.getValue(); + buffer.finishDrawing(); + Pair built = buffer.getNextBuffer(); + int count = built.getFirst().getVertexCount(); + JsonObject batch = new JsonObject(); + batches.add(batch); + batch.addProperty("vertex_count", count); + batch.addProperty("format", built.getFirst().getFormat().toString()); + JsonArray raw = new JsonArray(); + batch.add("vertices_xyz_uv_normal_alpha", raw); + require(count <= 4096, "Renderer exceeded the bounded geometry fixture"); + VertexFormat format = built.getFirst().getFormat(); + ByteBuffer data = built.getSecond(); + int p = offset(format, VertexFormatElement.Usage.POSITION, 0); + int uv = offset(format, VertexFormatElement.Usage.UV, 0); + int n = offset(format, VertexFormatElement.Usage.NORMAL, 0); + int c = offset(format, VertexFormatElement.Usage.COLOR, 0); + for (int i = 0; i < count; i++) { + int base = i * format.getSize(); + float[] vertex = {data.getFloat(base + p), data.getFloat(base + p + 4), data.getFloat(base + p + 8), + data.getFloat(base + uv), data.getFloat(base + uv + 4), data.get(base + n) / 127F, + data.get(base + n + 1) / 127F, data.get(base + n + 2) / 127F, (data.get(base + c + 3) & 255) / 255F}; + vertices.add(vertex); + JsonArray values = new JsonArray(); + for (float value : vertex) { + if (Float.isFinite(value)) values.add(value); else values.add(String.valueOf(value)); + } + raw.add(values); + } + // Inspect the texture actually selected by the production RenderType, not a substitute type. + Object state = valueOfType(entry.getKey(), RenderType.State.class); + Object textureState = valueOfType(state, RenderState.TextureState.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(); + batch.addProperty("texture", selectedTexture.toString()); + require(expectedTexture.equals(selectedTexture), "Production renderer selected the wrong texture"); + texture(minecraft, selectedTexture, batch); + } + } + } + + private static void mesh(List vertices, int count) { + require(vertices.size() == count, "Unexpected geometry vertex count: " + vertices.size() + ", expected " + count); + for (float[] v : vertices) { + for (float value : v) require(Float.isFinite(value), "Renderer emitted a non-finite vertex attribute"); + require(v[3] >= -EPSILON && v[3] <= 1 + EPSILON && v[4] >= -EPSILON && v[4] <= 1 + EPSILON, "Renderer UV escaped its texture"); + float length = v[5] * v[5] + v[6] * v[6] + v[7] * v[7]; + require(length > 0.9F && length < 1.1F && v[8] > 0F, "Renderer emitted an invalid normal or invisible vertex"); + } + for (int i = 0; i < vertices.size(); i += 4) { + float[] a = vertices.get(i), b = vertices.get(i + 1), c = vertices.get(i + 2); + float x = (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]); + float y = (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]); + float z = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + require(x * x + y * y + z * z > 0.00000001F, "Renderer emitted a blank/degenerate quad"); + } + } + + private static void beam(List vertices, Direction facing, float[] origin, float length) { + int axis = facing.getAxis() == Direction.Axis.X ? 0 : facing.getAxis() == Direction.Axis.Y ? 1 : 2; + int sign = facing.getAxisDirection() == Direction.AxisDirection.POSITIVE ? 1 : -1; + float start = Float.POSITIVE_INFINITY, end = Float.NEGATIVE_INFINITY; + for (float[] vertex : vertices) { + float along = (vertex[axis] - origin[axis]) * sign; + start = Math.min(start, along); + end = Math.max(end, along); + require(along >= -EPSILON && along <= length + EPSILON, "Beam points away from " + facing); + for (int i = 0; i < 3; i++) if (i != axis) require(Math.abs(vertex[i] - origin[i]) <= 0.125F + EPSILON, "Beam radius/axis changed"); + } + require(close(start, 0) && close(end, length), "Beam cardinal bounds/length changed"); + } + + private static void texture(Minecraft minecraft, ResourceLocation location, JsonObject row) throws IOException { + try (IResource resource = minecraft.getResourceManager().getResource(location)) { + BufferedImage image = ImageIO.read(resource.getInputStream()); + require(image != null && image.getWidth() > 0 && image.getHeight() > 0, "Renderer texture is not a readable image: " + location); + row.addProperty("texture", location.toString()); + row.addProperty("texture_width", image.getWidth()); + row.addProperty("texture_height", image.getHeight()); + } + } + + private static Object valueOfType(Object object, Class type) throws IllegalAccessException { + Field found = null; + for (Class owner = object.getClass(); owner != null; owner = owner.getSuperclass()) for (Field field : owner.getDeclaredFields()) { + if (field.getType() == type) { + require(found == null, "Ambiguous renderer field type " + type.getName()); + found = field; + } + } + require(found != null, "Missing renderer field type " + type.getName()); + found.setAccessible(true); + return found.get(Modifier.isStatic(found.getModifiers()) ? null : object); + } + + private static void set(Object object, String name, Object value) throws ReflectiveOperationException { + for (Class owner = object.getClass(); owner != null; owner = owner.getSuperclass()) { + try { + Field field = owner.getDeclaredField(name); + require(!Modifier.isStatic(field.getModifiers()) && !Modifier.isFinal(field.getModifiers()), "Fixture may only set mutable instance inputs"); + field.setAccessible(true); + field.set(object, value); + return; + } catch (NoSuchFieldException ignored) { } + } + throw new NoSuchFieldException(object.getClass().getName() + "." + name); + } + + private static int offset(VertexFormat format, VertexFormatElement.Usage usage, int index) { + for (int i = 0; i < format.getElements().size(); i++) { + VertexFormatElement element = format.getElements().get(i); + if (element.getUsage() == usage && element.getIndex() == index) return format.getOffset(i); + } + throw new IllegalStateException("Native vertex format lacks " + usage + " " + index); + } + + private static boolean different(List a, List b) { + if (a.size() != b.size()) return true; + for (int i = 0; i < a.size(); i++) for (int j = 0; j < 3; j++) if (!close(a.get(i)[j], b.get(i)[j])) return true; + return false; + } + + private static JsonArray ranges(List vertices, int first, int count) { + JsonArray result = new JsonArray(); + for (int i = first; i < first + count; i++) { + JsonArray range = new JsonArray(); + float low = min(vertices, i), high = max(vertices, i); + if (Float.isFinite(low)) range.add(low); else range.add(String.valueOf(low)); + if (Float.isFinite(high)) range.add(high); else range.add(String.valueOf(high)); + result.add(range); + } + return result; + } + + private static float min(List vertices, int axis) { + float value = Float.POSITIVE_INFINITY; + for (float[] vertex : vertices) value = Math.min(value, vertex[axis]); + return value; + } + + private static float max(List vertices, int axis) { + float value = Float.NEGATIVE_INFINITY; + for (float[] vertex : vertices) value = Math.max(value, vertex[axis]); + return value; + } + + private interface Check { void run(JsonObject row) throws Exception; } + + private static void run(JsonArray cases, String name, Check check) { + JsonObject row = new JsonObject(); + row.addProperty("case", name); + cases.add(row); + try { + check.run(row); + if (!row.has("status")) row.addProperty("status", "PASS"); + } catch (Exception | LinkageError | AssertionError failure) { + row.addProperty("status", "FAIL"); + row.addProperty("error", failure.toString()); + } + } + + private static boolean close(float a, float b) { return Math.abs(a - b) <= EPSILON; } + private static void require(boolean condition, String message) { if (!condition) throw new IllegalStateException(message); } +} 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 08d1b17..cd9fd6c 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 @@ -45,8 +45,8 @@ public final class ClientResourceProbe { try { result.addProperty("minecraft", net.minecraftforge.versions.mcp.MCPVersion.getMCVersion()); result.addProperty("forge", net.minecraftforge.versions.forge.ForgeVersion.getVersion()); - require("1.14.4".equals(result.get("minecraft").getAsString()) - && "28.2.26".equals(result.get("forge").getAsString()), "Wrong native client target"); + require("1.15.2".equals(result.get("minecraft").getAsString()) + && "31.2.57".equals(result.get("forge").getAsString()), "Wrong native client target"); require(minecraft.world == null && minecraft.player == null && !minecraft.isIntegratedServerRunning(), "Resource probe must not open a world or connect to a server"); com.google.gson.JsonArray mods = new com.google.gson.JsonArray(); @@ -64,6 +64,10 @@ public final class ClientResourceProbe { result.add("mod_versions", versions); require(versions.has("poweradvantage") && zone.moddev.mc.poweradvantage.PowerAdvantage.VERSION .equals(versions.get("poweradvantage").getAsString()), "Power metadata does not match the compiled version"); + require("2.4.6.115021".equals(versions.get("poweradvantage").getAsString()), "Wrong native Power candidate"); + for (String id : new String[]{"steamadvantage", "electricadvantage"}) { + if (versions.has(id)) require("2.2.7.115021".equals(versions.get(id).getAsString()), "Wrong native expansion candidate: " + id); + } require(minecraft.getModelManager().getMissingModel() != null, "Timed out before model baking"); require(minecraft.getLoadingGui() == null, "Timed out before native resource loading completed"); int items = 0; @@ -98,6 +102,8 @@ public final class ClientResourceProbe { TankVisualProbe.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); @@ -127,7 +133,7 @@ private static void verifyModel(IBakedModel model, IBakedModel missing, BlockSta List quads = new ArrayList<>(model.getQuads(state, null, random)); for (Direction side : Direction.values()) quads.addAll(model.getQuads(state, side, random)); require(!requireQuads || !quads.isEmpty(), "Empty baked model: " + id); - for (BakedQuad quad : quads) require(!"missingno".equals(quad.getSprite().getName().getPath()), "Missing face texture: " + id); + for (BakedQuad quad : quads) require(!"missingno".equals(quad.func_187508_a().getName().getPath()), "Missing face texture: " + id); } private static void require(boolean condition, String message) { 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 5cb199a..e7fc4f2 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,9 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.BufferBuilder; import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.renderer.texture.AtlasTexture; import net.minecraft.client.renderer.vertex.DefaultVertexFormats; +import net.minecraft.client.renderer.vertex.VertexFormatElement; import net.minecraft.fluid.Fluid; import net.minecraft.fluid.FlowingFluid; import net.minecraft.fluid.IFluidState; @@ -14,9 +16,10 @@ import net.minecraft.item.ItemStack; import net.minecraft.util.ResourceLocation; import net.minecraft.util.math.BlockPos; -import net.minecraft.world.IEnviromentBlockReader; +import net.minecraft.world.ILightReader; import net.minecraft.world.LightType; -import net.minecraft.world.biome.Biome; +import net.minecraft.world.level.ColorResolver; +import net.minecraft.world.lighting.WorldLightManager; import net.minecraft.tileentity.TileEntity; import zone.moddev.mc.poweradvantage.init.Fluids; import zone.moddev.mc.poweradvantage.init.Items; @@ -71,23 +74,29 @@ private static void requireBucket(net.minecraft.item.Item bucket, Fluid expected private static boolean check(Minecraft minecraft, IFluidState state, String texture, boolean flowing, JsonArray cases) { // A tiny read-only neighbourhood supplies a slope for flowing sprites; no save is opened. - IEnviromentBlockReader world = new IEnviromentBlockReader() { + ILightReader world = new ILightReader() { @Override public TileEntity getTileEntity(BlockPos pos) { return null; } @Override public BlockState getBlockState(BlockPos pos) { return blockAt(state, flowing, pos); } @Override public IFluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); } @Override public int getLightFor(LightType type, BlockPos pos) { return 15; } - @Override public Biome getBiome(BlockPos pos) { return net.minecraft.world.biome.Biomes.PLAINS; } + @Override public WorldLightManager getLightManager() { throw new UnsupportedOperationException("Synthetic light is supplied by getLightFor"); } + @Override public int getBlockColor(BlockPos pos, ColorResolver resolver) { + return resolver.getColor(net.minecraft.world.biome.Biomes.PLAINS, pos.getX(), pos.getZ()); + } }; BufferBuilder buffer = new BufferBuilder(4096); buffer.begin(7, DefaultVertexFormats.BLOCK); boolean rendered = minecraft.getBlockRendererDispatcher().renderFluid(BlockPos.ZERO, world, buffer, state); buffer.finishDrawing(); - TextureAtlasSprite sprite = minecraft.getTextureMap().getSprite(new ResourceLocation(texture)); - require(rendered && buffer.getVertexCount() >= 4, "Native fluid renderer emitted no top face for " + state); - ByteBuffer data = buffer.getByteBuffer(); - float u = data.getFloat(DefaultVertexFormats.BLOCK.getUvOffsetById(0)); - float v = data.getFloat(DefaultVertexFormats.BLOCK.getUvOffsetById(0) + 4); - int colorOffset = DefaultVertexFormats.BLOCK.getColorOffset(); + com.mojang.datafixers.util.Pair built = buffer.getNextBuffer(); + int vertices = built.getFirst().getVertexCount(); + TextureAtlasSprite sprite = minecraft.getAtlasSpriteGetter(AtlasTexture.LOCATION_BLOCKS_TEXTURE).apply(new ResourceLocation(texture)); + require(rendered && vertices >= 4, "Native fluid renderer emitted no top face for " + state); + ByteBuffer data = built.getSecond(); + int uvOffset = elementOffset(VertexFormatElement.Usage.UV, 0); + float u = data.getFloat(uvOffset); + float v = data.getFloat(uvOffset + 4); + int colorOffset = elementOffset(VertexFormatElement.Usage.COLOR, 0); int rgb = (data.get(colorOffset) & 255) << 16 | (data.get(colorOffset + 1) & 255) << 8 | data.get(colorOffset + 2) & 255; boolean correctSprite = !"missingno".equals(sprite.getName().getPath()) && u >= sprite.getMinU() && u <= sprite.getMaxU() && v >= sprite.getMinV() && v <= sprite.getMaxV(); @@ -98,9 +107,17 @@ private static boolean check(Minecraft minecraft, IFluidState state, String text entry.addProperty("expected_sprite", texture); entry.addProperty("correct_sprite", correctSprite); entry.addProperty("vertex_rgb", String.format("%06X", rgb)); - entry.addProperty("vertices", buffer.getVertexCount()); + entry.addProperty("vertices", vertices); cases.add(entry); - return rendered && buffer.getVertexCount() > 0 && correctSprite && correctTint; + return rendered && vertices > 0 && correctSprite && correctTint; + } + + private static int elementOffset(VertexFormatElement.Usage usage, int index) { + for (int i = 0; i < DefaultVertexFormats.BLOCK.getElements().size(); i++) { + VertexFormatElement element = DefaultVertexFormats.BLOCK.getElements().get(i); + if (element.getUsage() == usage && element.getIndex() == index) return DefaultVertexFormats.BLOCK.getOffset(i); + } + throw new IllegalStateException("Native block vertex format lacks " + usage + " " + index); } private static BlockState blockAt(IFluidState state, boolean flowing, BlockPos pos) { 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 5defe47..6115624 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 @@ -24,7 +24,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.recipebook.RecipeWidget; import net.minecraft.client.gui.recipebook.RecipeList; -import com.mojang.blaze3d.platform.GlStateManager; +import com.mojang.blaze3d.systems.RenderSystem; import net.minecraft.client.shader.Framebuffer; import net.minecraft.client.util.ClientRecipeBook; import net.minecraft.item.crafting.IRecipe; @@ -54,15 +54,15 @@ static void verify(Minecraft minecraft, JsonObject report) { net.minecraft.nbt.CompoundNBT saved; try (java.io.InputStream input = Files.newInputStream(root.resolve("server-registries.dat"))) { net.minecraft.nbt.CompoundNBT level = net.minecraft.nbt.CompressedStreamTools.readCompressed(input); - require("1.14.4".equals(level.getCompound("Data").getCompound("Version").getString("Name")), - "Recipe fixture is not a native 1.14.4 server export"); + require("1.15.2".equals(level.getCompound("Data").getCompound("Version").getString("Name")), + "Recipe fixture is not a native 1.15.2 server export"); net.minecraft.nbt.ListNBT mods = level.getCompound("fml").getList("LoadingModList", 10); boolean nativeForge = false; for (int i = 0; i < mods.size(); i++) { net.minecraft.nbt.CompoundNBT mod = mods.getCompound(i); - if ("forge".equals(mod.getString("ModId"))) nativeForge = "28.2.26".equals(mod.getString("ModVersion")); + if ("forge".equals(mod.getString("ModId"))) nativeForge = "31.2.57".equals(mod.getString("ModVersion")); } - require(nativeForge, "Recipe fixture was not saved by Forge 28.2.26"); + require(nativeForge, "Recipe fixture was not saved by Forge 31.2.57"); saved = level.getCompound("fml").getCompound("Registries"); } String itemRegistry = net.minecraftforge.registries.ForgeRegistries.ITEMS.getRegistryName().toString(); @@ -142,15 +142,15 @@ private static void render(Minecraft minecraft, ClientRecipeBook book) throws Ex int width = 1000, height = 180; Framebuffer target = new Framebuffer(width, height, true, Minecraft.IS_RUNNING_ON_MAC); target.bindFramebuffer(true); - GlStateManager.matrixMode(GL_PROJECTION); - GlStateManager.pushMatrix(); GlStateManager.loadIdentity(); - GlStateManager.ortho(0, width, height, 0, -1000, 1000); - GlStateManager.matrixMode(GL_MODELVIEW); - GlStateManager.pushMatrix(); GlStateManager.loadIdentity(); - GlStateManager.disableDepthTest(); + RenderSystem.matrixMode(GL_PROJECTION); + RenderSystem.pushMatrix(); RenderSystem.loadIdentity(); + RenderSystem.ortho(0, width, height, 0, -1000, 1000); + RenderSystem.matrixMode(GL_MODELVIEW); + RenderSystem.pushMatrix(); RenderSystem.loadIdentity(); + RenderSystem.disableDepthTest(); try { - GlStateManager.clearColor(0.12F, 0.12F, 0.12F, 1F); - GlStateManager.clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT, Minecraft.IS_RUNNING_ON_MAC); + RenderSystem.clearColor(0.12F, 0.12F, 0.12F, 1F); + RenderSystem.clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT, Minecraft.IS_RUNNING_ON_MAC); for (int i = 0; i < shown.size(); i++) { int x = 10 + (i % 5) * 198, y = 12 + (i / 5) * 55; RecipeWidget button = new RecipeWidget(); @@ -164,11 +164,11 @@ private static void render(Minecraft minecraft, ClientRecipeBook book) throws Ex } button.setPosition(x, y); button.render(-1, -1, 1F); - GlStateManager.disableLighting(); + RenderSystem.disableLighting(); minecraft.fontRenderer.drawString(shown.get(i).getRecipes().get(0).getRecipeOutput().getDisplayName().getFormattedText(), x + 30, y + 8, 0xFFFFFF); } ByteBuffer pixels = ByteBuffer.allocateDirect(width * height * 4); - GlStateManager.readPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + 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++) { @@ -180,10 +180,10 @@ private static void render(Minecraft minecraft, ClientRecipeBook book) throws Ex ImageIO.write(image, "png", new File("recipe-book-cells.png")); require(colored > 1000, "Native recipe buttons rendered blank"); } finally { - GlStateManager.popMatrix(); GlStateManager.matrixMode(GL_PROJECTION); - GlStateManager.popMatrix(); GlStateManager.matrixMode(GL_MODELVIEW); + RenderSystem.popMatrix(); RenderSystem.matrixMode(GL_PROJECTION); + RenderSystem.popMatrix(); RenderSystem.matrixMode(GL_MODELVIEW); target.deleteFramebuffer(); minecraft.getFramebuffer().bindFramebuffer(true); - GlStateManager.enableDepthTest(); + RenderSystem.enableDepthTest(); } } 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 9bdd4a9..da6f2d5 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 @@ -6,6 +6,7 @@ import net.minecraft.block.BlockState; import net.minecraft.client.Minecraft; import com.mojang.blaze3d.platform.GlStateManager; +import com.mojang.blaze3d.systems.RenderSystem; import net.minecraft.client.shader.Framebuffer; import net.minecraft.entity.player.PlayerInventory; import net.minecraft.fluid.IFluidState; @@ -89,25 +90,25 @@ private static int renderWater(Minecraft minecraft, MetalTankTileEntity tile) { tile.getTank().setFluid(new cyano.poweradvantage.api.fluid.FluidStack(net.minecraft.fluid.Fluids.WATER, 10000)); Framebuffer framebuffer = new Framebuffer(width, height, true, Minecraft.IS_RUNNING_ON_MAC); framebuffer.bindFramebuffer(true); - GlStateManager.matrixMode(GL_PROJECTION); - GlStateManager.pushMatrix(); - GlStateManager.loadIdentity(); - GlStateManager.ortho(0.0D, width, height, 0.0D, -1.0D, 1.0D); - GlStateManager.matrixMode(GL_MODELVIEW); - GlStateManager.pushMatrix(); - GlStateManager.loadIdentity(); - GlStateManager.disableLighting(); - GlStateManager.disableDepthTest(); - GlStateManager.enableBlend(); - GlStateManager.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA); - GlStateManager.enableTexture(); - GlStateManager.color4f(1F, 1F, 1F, 1F); + RenderSystem.matrixMode(GL_PROJECTION); + RenderSystem.pushMatrix(); + RenderSystem.loadIdentity(); + RenderSystem.ortho(0.0D, width, height, 0.0D, -1.0D, 1.0D); + RenderSystem.matrixMode(GL_MODELVIEW); + RenderSystem.pushMatrix(); + RenderSystem.loadIdentity(); + RenderSystem.disableLighting(); + RenderSystem.disableDepthTest(); + RenderSystem.enableBlend(); + RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA); + RenderSystem.enableTexture(); + RenderSystem.color4f(1F, 1F, 1F, 1F); try { - GlStateManager.clearColor(0F, 0F, 0F, 1F); - GlStateManager.clear(GL_COLOR_BUFFER_BIT, Minecraft.IS_RUNNING_ON_MAC); + RenderSystem.clearColor(0F, 0F, 0F, 1F); + RenderSystem.clear(GL_COLOR_BUFFER_BIT, Minecraft.IS_RUNNING_ON_MAC); definition.drawGUIDecorations(tile, screen, 0, 0, 0F); ByteBuffer pixels = ByteBuffer.allocateDirect(width * height * 4); - GlStateManager.readPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + 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++) { @@ -121,13 +122,13 @@ private static int renderWater(Minecraft minecraft, MetalTankTileEntity tile) { } catch (IOException failure) { throw new IllegalStateException("Could not retain the native tank gauge image", failure); } finally { - GlStateManager.popMatrix(); - GlStateManager.matrixMode(GL_PROJECTION); - GlStateManager.popMatrix(); - GlStateManager.matrixMode(GL_MODELVIEW); + RenderSystem.popMatrix(); + RenderSystem.matrixMode(GL_PROJECTION); + RenderSystem.popMatrix(); + RenderSystem.matrixMode(GL_MODELVIEW); framebuffer.deleteFramebuffer(); minecraft.getFramebuffer().bindFramebuffer(true); - GlStateManager.enableDepthTest(); + RenderSystem.enableDepthTest(); } } diff --git a/test-rig/client-resources/resources/META-INF/mods.toml b/test-rig/client-resources/resources/META-INF/mods.toml index 421aeef..2194c71 100644 --- a/test-rig/client-resources/resources/META-INF/mods.toml +++ b/test-rig/client-resources/resources/META-INF/mods.toml @@ -1,5 +1,5 @@ modLoader="javafml" -loaderVersion="[28,29)" +loaderVersion="[31,32)" license="GPL-3.0-only" [[mods]] @@ -11,20 +11,20 @@ description='''Build-only model, texture and translation checks. Never distribut [[dependencies.advantageclientresources]] modId="poweradvantage" mandatory=true -versionRange="[2.4.5.114041,2.5.0)" +versionRange="[2.4.6.115021]" ordering="AFTER" side="CLIENT" [[dependencies.advantageclientresources]] modId="forge" mandatory=true -versionRange="[28.2.26]" +versionRange="[31.2.57]" ordering="NONE" side="CLIENT" [[dependencies.advantageclientresources]] modId="minecraft" mandatory=true -versionRange="[1.14.4]" +versionRange="[1.15.2]" ordering="NONE" side="CLIENT" diff --git a/test-rig/client-resources/resources/pack.mcmeta b/test-rig/client-resources/resources/pack.mcmeta index ba29765..225b53c 100644 --- a/test-rig/client-resources/resources/pack.mcmeta +++ b/test-rig/client-resources/resources/pack.mcmeta @@ -1,6 +1,6 @@ { "pack": { "description": "Build-only Advantage client resource checks", - "pack_format": 4 + "pack_format": 5 } } diff --git a/test-rig/client-resources/run.ps1 b/test-rig/client-resources/run.ps1 index f1cbe84..78250b7 100644 --- a/test-rig/client-resources/run.ps1 +++ b/test-rig/client-resources/run.ps1 @@ -2,6 +2,7 @@ param([Parameter(Mandatory = $true)][string]$JavaHome, [ValidateSet('runClient.launch', 'runClientAllAdvantage.launch')][string]$LaunchProfile = 'runClient.launch', [ValidateSet('Gradle', 'Eclipse')][string]$OutputSource = 'Gradle', [switch]$RequireExtensions, + [switch]$ChildRendererGeometry, [string]$RecipeBookFixtures, [string]$PowerCandidate, [switch]$PreflightOnly, @@ -28,18 +29,36 @@ 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]*\.114\d+"[ \t]*(?:#[^\r\n]*)?\r?$' -or - $Metadata -notmatch '(?m)^loaderVersion\s*=\s*"\[28,') { throw "Not a native Forge 28 candidate: $Id" } + $header -notmatch '(?m)^version\s*=\s*"[^"\r\n]*\.115\d+"[ \t]*(?:#[^\r\n]*)?\r?$' -or + $Metadata -notmatch '(?m)^loaderVersion\s*=\s*"\[31,') { throw "Not a native Forge 31 candidate: $Id" } + $expected = @{ poweradvantage = '2.4.6.115021'; steamadvantage = '2.2.7.115021'; electricadvantage = '2.2.7.115021' } + 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.15.2' -or $Environment.FORGE_VERSION -ne '31.2.57' -or + $Environment.MCP_MAPPINGS -ne 'snapshot_20200514-1.15.1' -or $Environment.MCP_VERSION -ne '20200515.085601' -or + $Environment.target -ne 'fmluserdevclient') { throw 'Regenerate the native 1.15.2 / Forge 31.2.57 snapshot 20200514-1.15.1 client launch.' } + if ($Attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -notmatch '1\.15\.2-31\.2\.57' -or + $Attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -match '1\.(?:13\.2|14\.4)|--(?:server|port|gameDir|world)\b' -or + $Attributes['org.eclipse.jdt.launching.MAIN_TYPE'] -ne 'net.minecraftforge.launcher.Main') { + throw 'Unexpected native launch arguments; no server connection or existing game directory is allowed.' + } +} +function Assert-NativeArchive([string]$Path) { + if ($Path -match '(?:forge|client-extra)-1\.(?:13\.2|14\.4)|[\\/]PA11[34][\\/]') { 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' $runtime = Join-Path $project ('build/client-resource-runs/' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) -$inputReport = [ordered]@{ minecraft = '1.14.4'; forge = '28.2.26'; qualification = 'UNQUALIFIED'; output_source = $OutputSource; launch_profile = $LaunchProfile; require_extensions = [bool]$RequireExtensions } +$inputReport = [ordered]@{ minecraft = '1.15.2'; forge = '31.2.57'; mappings = 'snapshot_20200514-1.15.1'; mcp = '20200515.085601'; qualification = 'UNQUALIFIED'; output_source = $OutputSource; launch_profile = $LaunchProfile; require_extensions = [bool]$RequireExtensions; child_renderer_geometry = [bool]$ChildRendererGeometry } $phase = 'launch-preflight' New-Item -ItemType Directory -Path $runtime | Out-Null try { +if ($ChildRendererGeometry -and -not $RequireExtensions) { throw 'Child renderer geometry requires -RequireExtensions.' } if (-not (Test-Path -LiteralPath $java)) { throw "Java 8 runtime not found: $java" } if ((Get-Content -LiteralPath (Join-Path $JavaHome 'release') -Raw) -notmatch '(?m)^JAVA_VERSION="1\.8\.') { throw 'Supply Java 8 for the native client.' } if (-not (Test-Path -LiteralPath $probe)) { throw 'Run gradlew.bat clientResourceProbeJar verifyEclipseProductionClasspath first.' } @@ -50,7 +69,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="\[28,29\)"' -or $probeMetadata -notmatch 'versionRange="\[1\.14\.4\]"') { throw 'Rebuild the native 1.14 probe; do not substitute the 1.13 probe.' } +if ($probeMetadata -notmatch 'loaderVersion="\[31,32\)"' -or $probeMetadata -notmatch 'versionRange="\[1\.15\.2\]"' -or + $probeMetadata -notmatch 'versionRange="\[31\.2\.57\]"' -or $probeMetadata -notmatch 'versionRange="\[2\.4\.6\.115021\]"') { throw 'Rebuild the native 1.15 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) { @@ -58,10 +78,7 @@ foreach ($map in $launch.launchConfiguration.mapAttribute) { foreach ($entry in $map.mapEntry) { $environment[$entry.key] = $entry.value } } } -if ($environment.MC_VERSION -ne '1.14.4' -or $environment.FORGE_VERSION -ne '28.2.26' -or $environment.target -ne 'fmluserdevclient') { throw 'Regenerate the native 1.14.4 / Forge 28.2.26 client launch.' } -if ($attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -notmatch '1\.14\.4-28\.2\.26' -or - $attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -match '1\.13\.2|--(?:server|port|gameDir|world)\b' -or - $attributes['org.eclipse.jdt.launching.MAIN_TYPE'] -ne 'net.minecraftforge.launcher.Main') { throw 'Unexpected native launch arguments; no server connection or existing game directory is allowed.' } +Assert-NativeLaunch $environment $attributes $modOutput = Join-Path $runtime 'poweradvantage' New-Item -ItemType Directory -Path $modOutput,(Join-Path $runtime 'mods') -Force | Out-Null if ($OutputSource -eq 'Eclipse') { @@ -84,7 +101,7 @@ $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-dev/PowerAdvantage-2.4.5.114041-deobf.jar' } +if (-not $PowerCandidate) { $PowerCandidate = Join-Path $project 'build/libs-dev/PowerAdvantage-2.4.6.115021-deobf.jar' } $PowerCandidate = (Resolve-Path -LiteralPath $PowerCandidate).Path Assert-NativeMetadata (Read-JarMetadata $PowerCandidate) 'poweradvantage' $inputReport.power_candidate = Get-Record $PowerCandidate @@ -121,7 +138,7 @@ $archives = @(foreach ($entry in $classpathNode.listEntry) { $archiveRecords = @() $runtimeCandidates = @{} foreach ($archive in $archives) { - if ($archive -match '(?:forge|client-extra)-1\.13\.2|[\\/]PA113[\\/]') { throw "Legacy runtime archive: $archive" } + Assert-NativeArchive $archive $record = Get-Record $archive $archiveRecords += $record $metadata = if ([IO.Path]::GetExtension($archive) -eq '.jar') { Read-JarMetadata $archive } @@ -136,11 +153,12 @@ foreach ($archive in $archives) { } $inputReport.archives = $archiveRecords $inputReport.runtime_candidates = $runtimeCandidates -if (-not ($archives -match 'forge-1\.14\.4-28\.2\.26-snapshot-20190719-1\.14\.3\.jar$')) { throw 'Missing actual native mapped Forge runtime.' } +if (-not ($archives -match 'forge-1\.15\.2-31\.2\.57-snapshot-20200514-1\.15\.1\.jar$')) { throw 'Missing actual native mapped Forge runtime.' } if ($RequireExtensions -and (-not $runtimeCandidates.ContainsKey('steamadvantage') -or -not $runtimeCandidates.ContainsKey('electricadvantage'))) { 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' } $environment.MOD_CLASSES = "poweradvantage%%$modOutput;poweradvantage%%$modOutput" $inputReport.environment = $environment $inputReport.program_arguments = $arguments @@ -174,7 +192,10 @@ $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.14.4' -or $result.forge -ne '28.2.26') { throw 'Result is not native 1.14.4 / Forge 28.2.26 evidence.' } +if ($result.minecraft -ne '1.15.2' -or $result.forge -ne '31.2.57') { throw 'Result is not native 1.15.2 / Forge 31.2.57 evidence.' } +if ($RecipeBookFixtures -and $result.recipe_book_grouping_status -ne 'PASS') { throw 'Native recipe fixtures were supplied but recipe-book grouping did not pass.' } +if ($ChildRendererGeometry -and ($result.primary_resource_status -ne 'PASS' -or $result.child_renderer_geometry_status -ne 'PASS_WITH_MANUAL_GAPS')) { throw 'Additional child renderer geometry did not pass its worldless scope; inspect the retained report.' } +if ($ChildRendererGeometry) { $inputReport.child_renderer_geometry_status = $result.child_renderer_geometry_status } foreach ($record in $archiveRecords) { if ((Get-FileHash -LiteralPath $record.path).Hash -ne $record.sha256) { throw "Dependency changed during the run: $($record.path)" } } @@ -184,6 +205,7 @@ if ($log -match "Unable to load model:.*${namespaces}:|Exception loading blockst throw "Client logged a model or texture error; inspect $runtime/logs/latest.log" } Write-Output "PASS: $($result.items) items, $($result.block_states) block states, $($result.creative_entries) creative entries. Evidence: $runtime" +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' } catch { # Preserve native partial results and all images; record launcher failures separately. diff --git a/test-rig/client-resources/test-geometry-contract.ps1 b/test-rig/client-resources/test-geometry-contract.ps1 new file mode 100644 index 0000000..9817431 --- /dev/null +++ b/test-rig/client-resources/test-geometry-contract.ps1 @@ -0,0 +1,42 @@ +$ErrorActionPreference = 'Stop' +$checks = 0 +function Assert-GeometryContract([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } + $script:checks++ +} +$relative = 'java/zone/moddev/mc/advantageworks/client' +$geometry = Get-Content -LiteralPath (Join-Path $PSScriptRoot "$relative/ChildRendererGeometryProbe.java") -Raw +$main = Get-Content -LiteralPath (Join-Path $PSScriptRoot "$relative/ClientResourceProbe.java") -Raw +$runner = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'run.ps1') -Raw +Assert-GeometryContract ($geometry -notmatch '(?m)^import zone\.moddev\.mc\.(?:steamadvantage|electricadvantage)\.') 'Child renderer probe must not add compile dependencies on the expansions.' +Assert-GeometryContract ($geometry -notmatch 'new (?:DrillBitRenderer|LaserDrillRenderer|LaserTurretRenderer|HydroturbineRenderer)|renderGeometry\(|Unsafe|allocateInstance|new ClientWorld|loadWorld\(') 'Use dispatcher-owned production renderer instances without bypassing construction or loading a world.' +foreach ($fragment in 'TileEntityRendererDispatcher.instance.getRenderer(tile)', 'minecraft.getRenderManager().renderers.get(type)', + 'renderer.render(tile, partial, matrices, buffers', 'buffer.begin(type.getDrawMode(), type.getVertexFormat())', + 'buffer.getNextBuffer()', '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', 'Production renderer did not restore its matrix stack', + 'position.equals(original.getMatrix()) && normal.equals(original.getNormal())', + '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', + 'Production renderer selected the wrong texture', 'ImageIO.read(resource.getInputStream())', + 'renderFailure.addSuppressed(failure)', 'child-renderer-geometry.json', + 'MANUAL_GAP_WORLD_REQUIRED', 'PASS_WITH_MANUAL_GAPS', + 'cases.size() == 26', 'passed == 25 && gaps == 1') { + Assert-GeometryContract ($geometry.Contains($fragment)) "Missing geometry contract: $fragment" +} +$geometryCall = $main.IndexOf('if (Boolean.getBoolean("advantageworks.childRendererGeometry")) ChildRendererGeometryProbe.verify(minecraft, result);') +Assert-GeometryContract ($geometryCall -gt $main.IndexOf('RecipeBookGroupingProbe.verify(minecraft, result);')) 'Optional geometry must follow all existing resource oracles.' +Assert-GeometryContract ($main.IndexOf('result.addProperty("primary_resource_status", "PASS");') -lt $geometryCall) 'Keep primary resource results separate from the extra renderer scope.' +Assert-GeometryContract ($runner.Contains('[switch]$ChildRendererGeometry') -and $runner.Contains('-Dadvantageworks.childRendererGeometry=true')) 'Additional renderer geometry must be explicitly selected.' +Assert-GeometryContract ($runner.Contains('if ($ChildRendererGeometry -and -not $RequireExtensions)')) 'Geometry must require the combined scope.' +$unchanged = @{ + OilRenderProbe = '3560449BA7DBB2AD60CDEF4FEB5A7DCB53EF66A0AA9A82EC538F4D1993CD8ECE' + RecipeBookGroupingProbe = 'C9E02B30693C7CCFA024290DE4B286E707BE5DD9BC07633704437FEA4E660376' + TankVisualProbe = '756DE3330EDF1A9E649A2CE06BEFD7AC4A1C5E39E4443EE69A0AFE1D33A1807F' +} +foreach ($name in $unchanged.Keys) { + Assert-GeometryContract ((Get-FileHash -LiteralPath (Join-Path $PSScriptRoot "$relative/$name.java")).Hash -eq $unchanged[$name]) "Primary capture/oracle file changed: $name" +} +Write-Output "PASS: $checks static additional-geometry wiring and primary-oracle preservation checks. No renderer, Java or client was run." diff --git a/test-rig/client-resources/test-launcher.ps1 b/test-rig/client-resources/test-launcher.ps1 index 2866c53..b9987a9 100644 --- a/test-rig/client-resources/test-launcher.ps1 +++ b/test-rig/client-resources/test-launcher.ps1 @@ -1,3 +1,4 @@ +param([switch]$StaticOnly) $ErrorActionPreference = 'Stop' $tokens = $null $errors = $null @@ -18,28 +19,65 @@ function Assert-Throws([scriptblock]$Action) { } $native = @' modLoader="javafml" -loaderVersion="[28,)" # native Forge +loaderVersion="[31,)" # native Forge [[mods]] # mandatory header modId="poweradvantage" # inline comment -version="2.4.5.114041" # native candidate +version="2.4.6.115021" # native candidate [[dependencies.poweradvantage]] modId="minecraft" mandatory=true -versionRange="[1.14.4]" +versionRange="[1.15.2]" '@ Assert-NativeMetadata $native 'poweradvantage' $checks++ -Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.5.114041','2.4.4.113021')) 'poweradvantage' } -Assert-Throws { Assert-NativeMetadata ($native.Replace('[28,)','[25,)')) 'poweradvantage' } -Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.5.114041','${version}')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.6.115021','2.4.4.113021')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.6.115021','2.4.5.114041')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.6.115021','2.4.5.115021')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('[31,)','[28,)')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.6.115021','${version}')) 'poweradvantage' } Assert-Throws { Assert-NativeMetadata $native 'electricadvantage' } Assert-Throws { Assert-NativeMetadata '' 'poweradvantage' } +foreach ($id in 'steamadvantage','electricadvantage') { + $expansion = $native.Replace('poweradvantage', $id).Replace('2.4.6.115021','2.2.7.115021') + Assert-NativeMetadata $expansion $id + $checks++ + Assert-Throws { Assert-NativeMetadata ($expansion.Replace('2.2.7.115021','2.2.6.115021')) $id } +} +$environment = @{ MC_VERSION = '1.15.2'; FORGE_VERSION = '31.2.57'; MCP_MAPPINGS = 'snapshot_20200514-1.15.1'; MCP_VERSION = '20200515.085601'; target = 'fmluserdevclient' } +$attributes = @{ 'org.eclipse.jdt.launching.PROGRAM_ARGUMENTS' = '--main net.minecraftforge.userdev.LaunchTesting --cache D:\native\1.15.2-31.2.57 --'; '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') { + $invalid = $environment.Clone() + $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') { + $invalid = $attributes.Clone() + $invalid['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] += ' ' + $argument + Assert-Throws { Assert-NativeLaunch $environment $invalid } +} +$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.15.2-31.2.57-snapshot-20200514-1.15.1.jar' +$checks++ +foreach ($path in 'D:\PA114\client.jar','D:\PA113\client.jar','D:\native\forge-1.14.4-28.2.26.jar','D:\native\client-extra-1.13.2.jar') { + Assert-Throws { Assert-NativeArchive $path } +} if ((Quote-Argument 'D:\native path\') -ne '"D:\native path\\"') { throw 'Trailing-backslash command-line quoting changed.' } $checks++ if ((Quote-Argument 'text"quoted') -ne '"text\"quoted"') { throw 'Embedded-quote command-line quoting changed.' } $checks++ +$starts = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -eq 'Start-Process' }, $true)) +if ($starts.Count -ne 1 -or $starts[0].Extent.Text -notmatch '-WindowStyle Hidden') { throw 'Native helper must launch hidden.' } +$checks++ +if ($StaticOnly) { + Write-Output "PASS: $checks static launcher parser, target guards and quoting checks. Native candidate/launch checks NOT RUN. No Java/client/world was started." + return +} $project = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path -$power = Join-Path $project 'build/libs-dev/PowerAdvantage-2.4.5.114041-deobf.jar' +$power = Join-Path $project 'build/libs-dev/PowerAdvantage-2.4.6.115021-deobf.jar' Assert-NativeMetadata (Read-JarMetadata $power) 'poweradvantage' $checks++ [xml]$launch = Get-Content -LiteralPath (Join-Path $project 'runClientAllAdvantage.launch') -Raw @@ -59,7 +97,4 @@ foreach ($entry in ($launch.launchConfiguration.listAttribute | Where-Object { $ } } if ($found.Count -ne 4) { throw 'The actual combined launch must expose all four native dependency/expansion candidates.' } -$starts = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -eq 'Start-Process' }, $true)) -if ($starts.Count -ne 1 -or $starts[0].Extent.Text -notmatch '-WindowStyle Hidden') { throw 'Native helper must launch hidden.' } -$checks++ Write-Output "PASS: $checks launcher parser, metadata, native input and quoting checks. No Java/client/world was started." diff --git a/test-rig/client-resources/test-probe-contract.ps1 b/test-rig/client-resources/test-probe-contract.ps1 new file mode 100644 index 0000000..b2c220f --- /dev/null +++ b/test-rig/client-resources/test-probe-contract.ps1 @@ -0,0 +1,65 @@ +param([string]$ReferenceRoot = (Join-Path $PSScriptRoot '../../../../PA114/PowerAdvantage/test-rig/client-resources')) +$ErrorActionPreference = 'Stop' +$ReferenceRoot = (Resolve-Path -LiteralPath $ReferenceRoot).Path +$referenceHashes = @{ + ClientResourceProbe = 'A6003A2EE76006C3BA3E279EC49E49DA69FD0A9B9146C7D0328288A85E173E02' + TankVisualProbe = '1256340A70F53F5B0D2C9B824DF5C9C6E2F0DFFAD1AFDB4EA560FE03B30137F4' + OilRenderProbe = 'DF697A6BE69453F4956C25756110F2BB51B2DE78D95F4BF79A7612B49F18B1BA' + RecipeBookGroupingProbe = '9D842426742BEC9B6E0BEB1C763CAF1CF0D9C96F006A53796459B4B92BA1F4F5' +} +$checks = 0 +function Assert-Contract([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } + $script:checks++ +} +function Normalize-Probe([string]$Source) { + $Source.Replace("`r`n", "`n").Replace('1.14.4', '1.15.2').Replace('28.2.26', '31.2.57'). + Replace('GlStateManager.', 'RenderSystem.').Replace('buffer.getVertexCount()', 'vertices'). + Replace('quad.getSprite()', 'quad.func_187508_a()') +} +$relative = 'java/zone/moddev/mc/advantageworks/client' +foreach ($name in 'ClientResourceProbe','TankVisualProbe','OilRenderProbe','RecipeBookGroupingProbe') { + $sourcePath = Join-Path $ReferenceRoot "$relative/$name.java" + $hash = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash + Assert-Contract ($hash -eq $referenceHashes[$name]) "Reference SHA-256 mismatch for $name.java; expected $($referenceHashes[$name]), got $hash. Do not substitute a different baseline." + $source = Get-Content -LiteralPath $sourcePath -Raw + $target = Get-Content -LiteralPath (Join-Path $PSScriptRoot "$relative/$name.java") -Raw + $expected = Normalize-Probe $source + $actual = Normalize-Probe $target + # Keep every original assertion, including its operands, thresholds and message. + foreach ($assertion in [regex]::Matches($expected, '(?s)\brequire\(.*?\);')) { + Assert-Contract ($actual.Contains($assertion.Value)) "$name lost an original assertion: $($assertion.Value)" + } + foreach ($literal in [regex]::Matches($expected, '"[^"\r\n]*(?::|\.png|\.bin|\.dat)[^"\r\n]*"')) { + Assert-Contract ($actual.Contains($literal.Value)) "$name changed a resource, fixture or diagnostic literal: $($literal.Value)" + } + if ($name -eq 'RecipeBookGroupingProbe') { + $expected = $expected.Replace('import com.mojang.blaze3d.platform.GlStateManager;', 'import com.mojang.blaze3d.systems.RenderSystem;') + Assert-Contract ($actual -ceq $expected) 'Recipe grouping/capture changed beyond native version and RenderSystem adaptation.' + } + if ($name -eq 'TankVisualProbe') { + $actual = $actual.Replace("import com.mojang.blaze3d.systems.RenderSystem;`n", '') + Assert-Contract ($actual -ceq $expected) 'Tank simulation, packets, floor checks or capture changed beyond RenderSystem adaptation.' + } + if ($name -eq 'OilRenderProbe') { + $bucket = [regex]::Match($expected, '(?s)private static void requireBucket\(.*?(?= private static boolean check)').Value + Assert-Contract ($bucket.Length -gt 0 -and $actual.Contains($bucket)) 'Oil bucket identity/capability assertions changed.' + $cases = [regex]::Match($expected, '(?s)boolean valid = true;.*?if \(!valid\).*?;').Value + Assert-Contract ($cases.Length -gt 0 -and $actual.Contains($cases)) 'Oil/water/lava cases, sprites or failure aggregation changed.' + Assert-Contract ($target.Contains('buffer.getNextBuffer()') -and $target.Contains('built.getFirst().getVertexCount()')) 'Read the completed native draw state, not the reset builder count.' + Assert-Contract ($target.Contains('element.getUsage() == usage && element.getIndex() == index')) 'Native vertex offsets must use the actual format elements.' + Assert-Contract ($target.Contains('finally { net.minecraft.tags.FluidTags.setCollection(previousTags); }')) 'Synthetic fluid tags must be restored.' + Assert-Contract ($target -notmatch 'IEnviromentBlockReader|getTextureMap\(|getByteBuffer\(|getUvOffsetById\(|getColorOffset\(') 'Removed 1.14 fluid/atlas/vertex APIs remain.' + } +} +$pack = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'resources/pack.mcmeta') -Raw | ConvertFrom-Json +Assert-Contract ($pack.pack.pack_format -eq 5) 'Native 1.15 probe resource pack must use format 5.' +$metadata = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'resources/META-INF/mods.toml') -Raw +foreach ($pin in 'loaderVersion="[31,32)"','versionRange="[1.15.2]"','versionRange="[31.2.57]"','versionRange="[2.4.6.115021]"') { + Assert-Contract ($metadata.Contains($pin)) "Missing native probe metadata pin: $pin" +} +$runner = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'run.ps1') -Raw +$failure = [regex]::Match($runner, '(?s)\} catch \{\s*# Preserve native partial results.*?\} finally \{').Value +Assert-Contract ($failure.Contains('runner-failure.json') -and $failure.Contains('if (-not (Test-Path')) 'Runner must preserve partial native results and write its failure separately.' +Assert-Contract ($runner -notmatch '\b(?:Remove-Item|Clear-Content)\b') 'Runner must not delete old or failed evidence.' +Write-Output "PASS: $checks static probe preservation checks against $ReferenceRoot. Compilation and graphical acceptance NOT RUN." diff --git a/test-rig/gradle.properties b/test-rig/gradle.properties index bc7ffc6..5afd504 100644 --- a/test-rig/gradle.properties +++ b/test-rig/gradle.properties @@ -1,9 +1,12 @@ org.gradle.jvmargs=-Xmx2G org.gradle.daemon=false -minecraft_version=1.14.4 -forge_version=28.2.26 +minecraft_version=1.15.2 +forge_version=31.2.57 mapping_channel=snapshot -mapping_version=20190719-1.14.3 -mcp_version=20190829.143755 -harness_version=1.1.0.114041 +mapping_version=20200514-1.15.1 +mcp_version=20200515.085601 +harness_version=1.1.0.115021 +poweradvantage_deobf_sha256=F5029356CB837E7D782BA59D4C3B961CE0AAA116B739F14DA63F0A13562A2D1F +poweradvantage_source_commit=75d24d2641e8123ff7a7f961d9a1e5a1d5203194 +poweradvantage_pin_status=API_PIN_VERIFIED_RUNTIME_UNSEALED diff --git a/test-rig/migration/audit_native115_endurance.py b/test-rig/migration/audit_native115_endurance.py new file mode 100644 index 0000000..32df81f --- /dev/null +++ b/test-rig/migration/audit_native115_endurance.py @@ -0,0 +1,57 @@ +"""Reconstruct every endurance verdict from retained raw station checkpoints.""" + +import argparse +from pathlib import Path +from unittest.mock import patch + +import endurance +from matrix import digest, fingerprint, write_json +from native115_endurance import audit +from native115_lifecycle import read_json + + +def replay(root): + result = audit(root) + report = read_json(root / "native115-endurance-results.json") + if fingerprint(Path(report["source"])) != report["source_fingerprint"]: + raise AssertionError("Protected source changed after the run") + for name, expected in report["helper_sha256"].items(): + if digest(Path(name)) != expected: + raise AssertionError("Recorded protocol source changed: " + name) + definitions_path = next(Path(p) for p in report["helper_sha256"] if p.endswith("works-1.13.2.json")) + definitions = {station["id"]: station for station in read_json(definitions_path)["stations"]} + for name in result["profiles"]: + runtime = root / name + rig = endurance.Rig(name, runtime, None, None, definitions) + setup = endurance.parse_snapshot(read_json(runtime / "advantage-works-results/E-02-checkpoint-setup.json")) + battery_pos = rig.pos("E-02", (3, 1, 12)) + rig.static_batteries = setup[battery_pos]["data"].get("Items", []) + ledger = read_json(runtime / "endurance-ledger.json") + for expected in ledger: + label = expected["label"] + for station in endurance.CHECKED: + document = read_json(runtime / "advantage-works-results" / f"{station}-checkpoint-{label}.json") + rig.documents[station] = document + rig.snapshots[station] = endurance.parse_snapshot(document) + # Suppress the oracle's ledger writer only; never change its checks. + with patch.object(endurance, "write_json"): + actual = rig.batch(label) + if actual != expected: + raise AssertionError(name + "/" + label + ": raw checkpoint verdict differs from ledger") + result["profiles"][name]["raw_checkpoint_replays"] = len(ledger) + result["profiles"][name]["station_snapshots_replayed"] = len(ledger) * len(endurance.CHECKED) + result["source_fingerprint_rechecked"] = True + result["protocol_hashes_rechecked"] = True + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.output.exists(): + raise FileExistsError(args.output) + result = replay(args.run.resolve()) + write_json(args.output, result) + print("PASS: raw endurance checkpoints, inventory acknowledgements, artifact pins and protected sources", flush=True) diff --git a/test-rig/migration/audit_native115_partial.py b/test-rig/migration/audit_native115_partial.py new file mode 100644 index 0000000..036b3d6 --- /dev/null +++ b/test-rig/migration/audit_native115_partial.py @@ -0,0 +1,310 @@ +"""Recompute native broader-network/drill verdicts without trusting run summaries.""" + +import argparse +import json +from pathlib import Path + +from audit_pipe_only import bridge_result +import extended_lifecycle as historical +import pipe_only_lifecycle +import lifecycle +from lifecycle import (LifecycleRun, at, changed_target_results, contents, counts, + parse, progress_results) +from matrix import digest, fingerprint, write_json +from native115 import pinned_profile +from native115_geometry import require_geometry +from native115_lifecycle import (load_event_issues, require_native_boot, + unload_event_issues, verify_staged) + + +def snapshot(runtime, label): + value = json.loads((runtime / "advantage-works-results" / ("lifecycle-" + label + ".json")).read_text()) + require_geometry(value) + return value + + +def geometry_evidence(runtime): + paths = sorted((runtime / "advantage-works-results").glob("lifecycle-*.json")) + if not paths: + raise AssertionError("No raw native geometry observations") + forced = 0 + for path in paths: + value = json.loads(path.read_text()) + geometry = require_geometry(value) + forced += geometry["observedForcedLevels"] == [31] + if not forced: + raise AssertionError("No observed live native forced ticket") + return dict(snapshots=len(paths), forced_ticket_snapshots=forced, + geometry=geometry) + + +def lifecycle_results(runtime, definition): + reader = LifecycleRun(None, None, None, definition) + reader.probe = lambda operation, label: snapshot(runtime, label) + movements = snapshot(runtime, "commissioned")["movements"] + recorded = {row["id"]: row for row in movements} + if len(recorded) != len(movements): + raise AssertionError("Duplicate raw movement witness") + computed = dict(move_cases={f["id"]: lifecycle.move_result(f, recorded[f["id"]]) + for f in definition if "from" in f}) + for label in [f"chunk-cycle-{n}" for n in range(1, 4)] + ["mining-chunk-reload", "midblock-chunk"]: + before, absent, loaded = (snapshot(runtime, label + suffix) + for suffix in ("-before", "-absent-confirmed", "-loaded")) + lifecycle.require_unloaded(before, absent) + if unload_event_issues(before, absent) or load_event_issues(before, loaded): + raise AssertionError("Missing actual lifecycle callback: " + label) + for fixture in definition: + for pos in [fixture["to"]] if "to" in fixture else fixture["witnesses"]: + old, new = at(before, fixture["id"], pos), at(loaded, fixture["id"], pos) + if (old["block"] != new["block"] or old["state"] != new["state"] + or contents(old) != contents(new)): + raise AssertionError("Chunk reload changed block/state/items: " + label) + if "tileIdentity" in old and old["tileIdentity"] == new.get("tileIdentity"): + raise AssertionError("Chunk reload reused old tile object: " + label) + if "Tank" in parse(old) and parse(old)["Tank"] != parse(new).get("Tank"): + raise AssertionError("Chunk reload changed tank: " + label) + for label, amount in [("commissioned", 1000), ("restart-retained", 4000), + ("restart-resumed", 5000)] + [(f"chunk-resumed-{n}", (n+1)*1000) for n in range(1, 4)]: + reader.processing(label, amount) + before, after = (snapshot(runtime, label) for label in ("before-restart", "after-restart")) + for fixture in definition: + if "to" not in fixture: + continue + old, new = at(before, fixture["id"], fixture["to"]), at(after, fixture["id"], fixture["to"]) + if (contents(old) != contents(new) or old["block"] != new["block"] + or "facing=" + fixture["facing"] not in new["state"]): + raise AssertionError("Moved drill changed across process restart") + computed.update(mining=reader.mining_results(snapshot(runtime, "restart-resumed")), + mining_after_chunk_reload=reader.mining_results(snapshot(runtime, "mining-retained")), + mid_block_progress=dict( + chunk_reload=progress_results(definition, snapshot(runtime, "midblock-before-chunk"), snapshot(runtime, "midblock-after-chunk")), + full_restart=progress_results(definition, snapshot(runtime, "midblock-before-restart"), snapshot(runtime, "midblock-after-restart"))), + save_read_guards=dict( + legacy_read=progress_results(definition, snapshot(runtime, "save-read-before"), snapshot(runtime, "save-read-legacy")), + changed_target_read=changed_target_results(definition, snapshot(runtime, "save-read-legacy"), snapshot(runtime, "save-read-changed")))) + groups = [computed["move_cases"], computed["mining"], computed["mining_after_chunk_reload"], + *computed["mid_block_progress"].values(), *computed["save_read_guards"].values()] + if any(len(group) != 12 for group in groups): + raise AssertionError("Incomplete twelve-route lifecycle group") + qualification = "PASS" if all(row["status"] == "PASS" for group in groups for row in group.values()) else "FAIL" + computed.update(chunk_cycles=5, full_restart="PASS", network_processing="PASS", fluid_mB=5000) + return computed, qualification, groups + + +def partial_cases(runtime, definition, cycles): + results = [] + for cycle, group in enumerate(cycles, 1): + label = f"partial-{cycle}-{group}" + samples = [snapshot(runtime, label + f"-sample-{n}") for n in range(8)] + confirmed = snapshot(runtime, label + "-confirmed") + confirmed["confirmed"] = True + results.append(dict(label=label, group=group, cases=historical.partial_result( + definition, snapshot(runtime, label + "-before"), samples + [confirmed], + snapshot(runtime, label + "-loaded"), group))) + return results + + +def order_cases(runtime, definition): + results = [] + for order in (("machine", "supply"), ("supply", "machine")): + label = "order-" + "-".join(order) + results.append(dict(label=label, order=list(order), cases=historical.order_results( + definition, *(snapshot(runtime, label + "-" + phase) for phase in + ("before", "absent", "first", "settled", "loaded")), order))) + return results + + +def check_processing(runtime, definition): + for label, quantity in [("network-commissioned", 1000)] + [ + (f"partial-resumed-{n}", (n + 1) * 1000) for n in range(1, 5)] + [ + ("order-machine-supply-resumed", 5000), ("order-supply-machine-resumed", 5000)]: + state = snapshot(runtime, label) + for f in definition: + if "groups" not in f: + continue + endpoint = at(state, f["id"], f["witnesses"][4]) + if f["id"] == "C-FLUID": + tank = parse(endpoint).get("Tank", {}) + if tank.get("FluidName") != "minecraft:water" or tank.get("Amount") != quantity: + raise AssertionError("Fluid conservation: " + label) + else: + items = counts([endpoint]) + if items["minecraft:iron_ingot"] != 4 or items["minecraft:iron_ore"] != 0 or sum(endpoint["energy"].values()) <= 0: + raise AssertionError("Network processing: " + label) + + +def check_canonical(runtime): + path = runtime / "native115-readiness.json" + records = json.loads(path.read_text()) if path.is_file() else [] + checked = 0 + for record in records: + if "canonical_label" not in record: + continue + source = runtime / "advantage-works-results" / ("lifecycle-" + record["selected_label"] + ".json") + canonical = runtime / "advantage-works-results" / ("lifecycle-" + record["canonical_label"] + ".json") + if source.read_bytes() != canonical.read_bytes(): + raise AssertionError("Canonical snapshot differs from actual raw bytes: " + canonical.name) + checked += 1 + return checked + + +def audit(root): + files = list(root.glob("native115-*-results.json")) + if len(files) != 1: + raise AssertionError("Specify one native campaign root") + report = json.loads(files[0].read_text()) + campaign = report.get("campaign", "lifecycle" if files[0].name == "native115-lifecycle-results.json" else "unknown") + if not report.get("source_fixture_unchanged") or not report.get("helpers_unchanged"): + raise AssertionError("Protected source/helper changed") + if fingerprint(Path(report["source"])) != report["sourceRuntimeBefore"]: + raise AssertionError("Protected source no longer matches recorded fingerprint") + if digest(Path(report["targetManifest"])) != report["targetManifestSha256"]: + raise AssertionError("Target manifest changed") + helpers = report.get("helpersBefore", report.get("helperSha256", {})) + if not helpers: + raise AssertionError("Missing archived protocol fingerprints") + for name, checksum in helpers.items(): + if digest(root / "protocol-sources" / name) != checksum: + raise AssertionError("Archived protocol differs: " + name) + target = pinned_profile(Path(report["targetManifest"]), "target115") + if set(report["profiles"]) != {"fresh115", "original110-chained113-native115"}: + raise AssertionError("Incomplete profile set") + definition = json.loads((root / "lifecycle-fixture.json").read_text()) + kind = campaign.removeprefix("halo-").removeprefix("holder-") + expected = (lifecycle.fixtures() if kind == "lifecycle" else historical.fixtures() if kind in ("extended", "extended-reads") else + pipe_only_lifecycle.fixtures() if kind == "bridge" else + [f for f in historical.fixtures() if "groups" in f]) + if campaign.startswith("halo-"): + from native115_ticket_networks import bridge_fixtures, extended_fixtures, linear_fixtures + expected = {"partial": linear_fixtures, "bridge": bridge_fixtures, "extended": extended_fixtures}[kind]() + if campaign.startswith("holder-"): + from native115_holder_networks import bridge_fixtures, extended_fixtures, linear_fixtures + expected = {"partial": linear_fixtures, "bridge": bridge_fixtures, "extended": extended_fixtures}[kind]() + if definition != json.loads(json.dumps(expected)): + raise AssertionError("Historical fixture geometry differs") + result = dict(evidence_audit="PASS", qualification="PASS", campaign=campaign, profiles={}) + for name, summary in report["profiles"].items(): + runtime = root / name + details = result["profiles"][name] = dict(evidence_audit="PASS", failures=[], machine_qualification="FAIL") + try: + verify_staged(runtime, target) + if json.loads((runtime / "lifecycle-fixture.json").read_text()) != definition: + raise AssertionError("Runtime fixture geometry changed") + details["raw_canonical_pairs"] = check_canonical(runtime) + details["native_geometry"] = geometry_evidence(runtime) + if campaign.startswith("halo-"): + if not summary.get("native_ticket_protocol_unchanged") or digest( + runtime / "native115-ticket-network-protocol.py") != summary["native_ticket_protocol_sha256"]: + raise AssertionError("Native ticket-control protocol changed") + if campaign.startswith("holder-"): + if not summary.get("holder_protocol_unchanged") or digest(runtime / "native115_holder_networks.py") != summary["holder_protocol_sha256"] \ + or digest(runtime / "native115_ticket_networks.py") != summary["holder_transport_sha256"]: + raise AssertionError("Native holder-control protocol changed") + if kind == "lifecycle": + computed, qualification, groups = lifecycle_results(runtime, definition) + details["drill_groups"] = [{"PASS": sum(c["status"] == "PASS" for c in group.values()), + "FAIL": sum(c["status"] != "PASS" for c in group.values())} for group in groups] + details["real_chunk_cycles"] = 5 + elif kind == "extended": + early = dict(insufficient_energy=historical.reserve_results( + definition, snapshot(runtime, "low-before"), snapshot(runtime, "low-stalled")), + moves=historical.movement_results(definition, snapshot(runtime, "step-4-complete"))) + details["completed_early_drill_groups"] = { + key: {"PASS": sum(c["status"] == "PASS" for c in group.values()), + "FAIL": sum(c["status"] != "PASS" for c in group.values())} + for key, group in early.items()} + details["native_moves"] = len(snapshot(runtime, "step-4-complete")["movements"]) + for key, value in early.items(): + if summary.get(key) != value: + raise AssertionError("Early verdict differs from raw evidence: " + key) + computed = dict( + insufficient_energy=historical.reserve_results(definition, snapshot(runtime, "low-before"), snapshot(runtime, "low-stalled")), + moves=historical.movement_results(definition, snapshot(runtime, "step-4-complete")), + partial_networks=partial_cases(runtime, definition, ("machine", "supply", "supply", "machine")), + reload_orders=order_cases(runtime, definition), + blocked_output=historical.output_results(definition, snapshot(runtime, "blocked-output"), True), + restart=historical.restart_results(definition, snapshot(runtime, "blocked-output"), snapshot(runtime, "blocked-retained")), + blocked_after_restart=historical.output_results(definition, snapshot(runtime, "blocked-retained"), True), + resumed_output=historical.output_results(definition, snapshot(runtime, "freed-output"), False), + redstone=historical.redstone_results(definition, *(snapshot(runtime, label) for label in + ("redstone-before", "redstone-held", "redstone-resumed"))), network_processing="PASS") + check_processing(runtime, definition) + qualification = historical.qualification(computed) + details["native_moves"] = len(snapshot(runtime, "step-4-complete")["movements"]) + details["drill_groups"] = {key: {"PASS": sum(c["status"] == "PASS" for c in computed[key].values()), + "FAIL": sum(c["status"] != "PASS" for c in computed[key].values())} + for key in ("insufficient_energy", "moves", "blocked_output", "restart", + "blocked_after_restart", "resumed_output", "redstone")} + elif kind == "extended-reads": + reader = LifecycleRun(None, None, None, definition) + computed = dict(moves=historical.movement_results(definition, snapshot(runtime, "step-4-complete")), + mining=reader.mining_results(snapshot(runtime, "reads-mining-complete")), + mid_block_progress=dict( + chunk_reload=progress_results(definition, snapshot(runtime, "midblock-before-chunk"), snapshot(runtime, "midblock-after-chunk")), + full_restart=progress_results(definition, snapshot(runtime, "midblock-before-restart"), snapshot(runtime, "midblock-after-restart"))), + saved_read_guards=dict( + legacy_read=progress_results(definition, snapshot(runtime, "save-read-before"), snapshot(runtime, "save-read-legacy")), + changed_target_read=changed_target_results(definition, snapshot(runtime, "save-read-legacy"), snapshot(runtime, "save-read-changed")))) + groups = [computed["moves"], computed["mining"], *computed["mid_block_progress"].values(), + *computed["saved_read_guards"].values()] + qualification = "PASS" if all(c["status"] == "PASS" for group in groups for c in group.values()) else "FAIL" + for f in definition: + if "route" in f and contents(at(snapshot(runtime, "save-read-before"), f["id"], f["to"])) != contents( + at(snapshot(runtime, "save-read-changed"), f["id"], f["to"])): + raise AssertionError("Saved-read item conservation") + if not summary.get("own_protocol_unchanged") or digest(runtime / "native115-extended-reads-protocol.py") != summary["protocolSha256"]: + raise AssertionError("Extra saved-read protocol changed") + details["native_moves"] = len(snapshot(runtime, "step-4-complete")["movements"]) + elif kind in ("partial", "empty-control"): + computed = dict(partial_networks=partial_cases(runtime, definition, ("machine", "supply")), + reload_orders=order_cases(runtime, definition)) + qualification = "PASS" if all(c["status"] == "PASS" for key in computed for row in computed[key] + for c in row["cases"].values()) else "FAIL" + elif kind == "bridge": + computed = dict(cycles=[dict(cycle=cycle, **bridge_result(definition, *(snapshot(runtime, + f"bridge-{cycle}-" + phase) for phase in ("before", "isolated", "confirmed", "loaded", "resumed")), cycle)) + for cycle in range(3)]) + qualification = "PASS" if all(c["status"] == "PASS" for c in computed["cycles"]) else "FAIL" + else: + raise ValueError("Unknown native campaign") + for key, value in computed.items(): + if summary.get(key) != value: + raise AssertionError("Summary differs from unchanged raw oracle: " + key) + if summary["status"] != qualification: + raise AssertionError("Reported machine status differs from raw evidence") + details["machine_qualification"] = qualification + except (AssertionError, FileNotFoundError, KeyError, ValueError) as error: + details.update(evidence_audit="FAIL", failures=[str(error)]) + logs = sorted(runtime.glob("console-*.log")) + details["boots"] = [log.name for log in logs] + details["timing_warnings"] = { + log.name: [line for line in log.read_text(encoding="utf-8", errors="replace").splitlines() + if "Can't keep up!" in line] for log in logs} + for log in logs: + try: + require_native_boot(log) + text = log.read_text(encoding="utf-8", errors="replace") + if "Stopping server" not in text: + raise AssertionError("No graceful stop: " + log.name) + if "An unexpected error occurred trying to execute that command" in text: + raise AssertionError("Native command exception retained: " + log.name) + except AssertionError as error: + details["failures"].append(str(error)) + if details["evidence_audit"] != "PASS": + result["evidence_audit"] = "FAIL" + if details["machine_qualification"] != "PASS" or details["failures"]: + result["qualification"] = "FAIL" + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.output.exists(): + raise FileExistsError(args.output) + result = audit(args.run) + write_json(args.output, result) + print(json.dumps(result, indent=2)) + raise SystemExit(int(result["evidence_audit"] != "PASS" or result["qualification"] != "PASS")) diff --git a/test-rig/migration/native115-historical-inputs.json b/test-rig/migration/native115-historical-inputs.json new file mode 100644 index 0000000..38c3113 --- /dev/null +++ b/test-rig/migration/native115-historical-inputs.json @@ -0,0 +1,120 @@ +{ + "sourceCommit": "ef08e5cd134dc75bd73518bf4c90a6711e67280f", + "scope": "Historical source/destination adapters, original semantic oracles, profiles and station specifications", + "hashes": { + "migration/actors.py": "529A775CCBEF6D7E2687262006A3781AEE4475EC5CE3ECD49F32084EBB874A3F", + "migration/actors_native.py": "14658DFEA1B966581EB1BD110139B59F576AF3BCB43B7CDA3D598160685F34DB", + "migration/audit_actors.py": "5D3AE0C4676AACE1F43953D47072D04B9A49932F14078220D559E7AD2906F2F6", + "migration/audit_actors_native.py": "3CAC4F1A2CF10183F828E64B8AE6A8D657EE74F689CEF5FACD4957805AD4875D", + "migration/audit_connected.py": "A8AE93E261658E637860A7A32D4A0074A2C7A31ED39A34F1C3747FDC369B8100", + "migration/audit_connected_progress.py": "2ADD1B2BCF43B88A14CEF5E870FC558175B422EF083DE9ED5A55CB91341A012E", + "migration/audit_dimensions.py": "CEA6968F976C20DD37BECA05D590788AC92A526D3A431B880BD94A5E65348C61", + "migration/audit_drill_restart.py": "43BF094EF333E4D8DE210A927ED73AE506ABA5464CBD07AEFF517EE1A967DF53", + "migration/audit_endurance.py": "2DCDE84D8435053661BF7D1D7E999200EFC59CD86CCD080CC89316F20FE6F306", + "migration/audit_extended_lifecycle.py": "C4388C06026BCFF33BA2F66D326AFA5B3041B00B6ED0AA1B7C627EF9BF12D111", + "migration/audit_lifecycle.py": "D868E311789E62D29632D9A4FC0B75B0A495A2DAAD39E309507AB552AA418D4F", + "migration/audit_native114_endurance.py": "4D1A7E5BCCFDF680135680DB2272FD23BA17E5C3BB45D60A0DE4C987CDA02C8A", + "migration/audit_native114_partial.py": "8FCE2D8F0B5305C2950E154396DC86F9C4EA1C9138BB22DF6DBCF568B420F004", + "migration/audit_pipe_only.py": "191C0453EFB0A8E866E0E9B814C325ECC212A4A9895AEA9E4965A2C7817424F6", + "migration/audit_player_layout.py": "95C672B76D5A0A34B36D22ECEBC2DFCF7425E66B9505E15FE9F9749436565008", + "migration/audit_respawn.py": "6A2EBDB6851BBE63CBAF55BDE523EEB62E28DE1913CBF6B1E9210DF6453427A6", + "migration/audit_results.py": "1EE3101218A551B9908BB480937DDAE0C2CF1ECC2EB97840725054B572B42E2E", + "migration/audit_transformer_order.py": "C8FADD3443ED62E5E8164604949E021BF6D11D10E72BE3FF030021332632C125", + "migration/connected.py": "9E34BAE4E9FDF43AC58C9897FA2F813C53C97145FBDE472102476DCAB4E1051E", + "migration/dimensions.py": "F23E39FD5C887B489F1B3FDEBC7B8E0835564171C30544A2F7638E89B2B169BA", + "migration/drill_restart.py": "6D4BB8AE22B3BA225A8E0455254C4BAEEF4E2973B06121F843A977B586AADFEF", + "migration/endurance.py": "F4E806E2D0C5280F7CEFF544EABB14D995E718142CCC71899F26FC00E8CE0B4E", + "migration/extended_lifecycle.py": "8A1E84C3E696EFAC711D3D0D847DD532253F57BFEF1A7BC93EF5529605A6280B", + "migration/lifecycle.py": "D8A8E5457FC6F1ED70E983482049CE79B831D2EFCE7AF0872B9B5EDE66411007", + "migration/matrix.py": "F0B1C19645464583E776849A82BE3F8EAAC5129A2B17614F222E7AAFA1E2B28E", + "migration/native114.py": "E5D5242E0E1E5F686AEB83E90726CB862CE288887185C15732E0084146DD8811", + "migration/native114_actors.py": "EA0726C8781BE9F9A5775C55DD5BF58138AF74645E8DEE1373E79190487F72E8", + "migration/native114_audit_actors.py": "3C14F1941779003A4AFA92CA35831F9D48B6046C74BA163621A294198657C43A", + "migration/native114_audit_order.py": "24CF9143345AC4033BF21C9C7BE7C93D150C3542CC8DC6D88BC80B4C4D42599F", + "migration/native114_audit_respawn.py": "45207C265C3F4C78FF9B70B13D2714EF72CFAEB86D1EED5E134CB1219DC38522", + "migration/native114_broad.py": "C5AD9B69465DD59312F2686059688727D0836BEFA9F31788DC44C3DD08A6CF23", + "migration/native114_connected.py": "5B350E7E58D41BA9428B13A1B4BBF730A990528AEF8E6D2FB2AB918D8B3D1FF3", + "migration/native114_endurance.py": "EA98C26B73FDC71F1E270B6E8753674940332C7D2029D144E60F65AECF02C929", + "migration/native114_extended.py": "717582DF6A7B6DD40D8BCB8953E6667FCA4B747AFDC8DF33B03876DD4944A6F4", + "migration/native114_extended_reads.py": "70F8A516DBEC561B1D7BC78740D429DE8C4CFFE21E40FEC0625350B43D7E4947", + "migration/native114_holder_networks.py": "F731C68EC40F3F52173701D38842FCC99FC13E77E0D0201EE323EDAE2A1C7830", + "migration/native114_interop.py": "636EA0DD2CA24BEEBB3E5F1EC2BD7227F8452A4F6ABD5734F9A1D2C862ED1F02", + "migration/native114_lifecycle.py": "D77A7B5FCAE05C1A02F513979AC91EA6BC161DB500F5467E6452A64F514E4812", + "migration/native114_oil.py": "100773BE25B4F504E603A9D9BAEB82FA17D2652C82B3AF3B2A09DD714EDAA942", + "migration/native114_partial.py": "8CC74C4ECDF7DD05CB0AFCFD5D55FAF7DAAC8A5C15F11FCFF441A7774DB0A148", + "migration/native114_partial_diagnostic.py": "1B0E478BD42BA166A164A1708CFC60A9CC8471F8EFE2D589CB8163622C98389A", + "migration/native114_player_layout.py": "3B025EA3DC574D952B05D5F6595E02BE8F8A15CF925AD1E35612F1234B92CB2A", + "migration/native114_recipes.py": "AF6583B249EBC3CAC6CC0645D287CA4D0D81B9AE8ACA402AF1799AE8BE30F750", + "migration/native114_refresh.py": "EF4950B50EB5B16651D6CC31171005B42E7DFD04D5D680578C73B17F9E7755A3", + "migration/native114_respawn.py": "94EAEA6CCD212947AB4BC31941C98D7F676D72C4D35321320656877553DA6895", + "migration/native114_ticket_networks.py": "40958F44AEDC20773994EEEA08095930BF0EE3AFAAA3ADAF8A97A68827660715", + "migration/native114_transformer_order.py": "4B9F5E23C178EB05AAF216CEE1B89A73080D9EB7018035B64422AF59CD8BB6E3", + "migration/oil_transfer.py": "85F60CFABFAF7107FAD461C6747319D027B9FF7DF85E303B7592F8FF78E9705F", + "migration/partial_network_trace.py": "34ADE03A034296E7CFB753777AE68126A134BC28C4870F9A73FCCA15D0B3F9F6", + "migration/pin_dimension_profiles.py": "4E79D6602CF217E3BB14C8E451C04EE1AABAE3EE65B902F250C3C20FE65D1D87", + "migration/pin_drill_profiles.py": "B58CBED8028BC4E01BC8655A65621C04F0452B6D6C193FE8DC59AB5BBF371829", + "migration/pipe_only_lifecycle.py": "F0F9C9B87C567315030B190C20A745B5319657ADE785583093E05013E14AB41F", + "migration/player_layout.py": "C2C669E64320A2196E4AA102D2B8A56FB193A46BEC72C55ADC6F2C98C6142B44", + "migration/progression.py": "951287B42ED8782C2C11003A8343959DCB2BCBA929FC6267FE1211AD72DC28F2", + "migration/recipe_unlocks.py": "9872F4BE7860798C8D14DB9CE1AD99F6E3D2D688A45EEF1932749DBA1BCC0552", + "migration/respawn.py": "E6E2E14A648AAE5E976BB7B1CAC15448CF8AB59820333CEC5F9D77D21FA77A28", + "migration/test_actors.py": "6F9625D1A658BFCD90EE34A819AEB72F5594CAB5B643FEB609B856E4B595A9AF", + "migration/test_audit_connected.py": "D0632277E35C8F24F9473C1B6E119C596D88E9737F0C630F7808F208100B4809", + "migration/test_bridge_profiles.py": "B2A34883F3413FC5F7A2D2130293BF15A0871182D381AF8A8753F2F799817F26", + "migration/test_connected.py": "1BE01FD1AAF31F3C9E5636AC0180E31E0E85F5ECD345559C562CD74CDA5888FD", + "migration/test_connected_progress.py": "E6FBC9A57C65A28367B7CC7C6CC95A9EE1B9B7D818892FC0B8BADD060B867B75", + "migration/test_dimensions.py": "3B8BEE2CE218AD7F96F9FA8159AB27C6816F897898D3B1C3B503D55983B491BB", + "migration/test_drill_restart.py": "DC3C434D1B7D21E277F7CC36B038AE41F29E5CBD6EB8666F93560A3EC1DC52DE", + "migration/test_endurance.py": "A466E75F91FDB5B955D6AA9210B3DA972759D671076C7E2F671379FBE582BF55", + "migration/test_extended_lifecycle.py": "7165AE933F6FF0FBA72A712521AC86F7BEB38EDDC07451CB4988BB60D7F0DCD2", + "migration/test_lifecycle.py": "0E7441A0A5C8B7D1C3B938939C95B4EC51905EEFA1073DF8F93ED6EC37D45903", + "migration/test_matrix.py": "7E83D7925F7213FB08B741AB3BCCE21911F58BBC9C219F5AD28D61E0D1BE1A72", + "migration/test_native114.py": "B0128C5052F9452539D5F5430550A48CA0AD3F93B3E6AEF58BCE12ADC31A04EC", + "migration/test_native114_actors.py": "415F78AA14AEE890EEFAD6296BD860580E8334E8DB78C1C52864FAFB12E76CB6", + "migration/test_native114_broad.py": "D69B593E494AEE7F69DB205234A900F74FFEBE0879E9E11188C6BF82C5EFDDB6", + "migration/test_native114_connected.py": "1E5AFD8A0266EE25FED1228E90CF29F69DA56FBEB71C9E4FDDB0155DB54025A8", + "migration/test_native114_endurance.py": "233D74BD1AE28FAD417A3B0E5986D76D5A1EC7BA2A1B884B866F81D65A526649", + "migration/test_native114_holder_networks.py": "F0A7DF1ECC95F3C83F5513612CF428589DC4C2D20AB083882B7579DEBE50B64D", + "migration/test_native114_interop.py": "7A443D703D0BA1BB5EF440377B78EA8151E9D2DD802F06798DB1EA4BE5CD65E9", + "migration/test_native114_lifecycle.py": "4882058C4DD26469F6F9EE7072EE923C7998F35D334DBAA2999811A16C38FABE", + "migration/test_native114_oil.py": "67664A8EB33B626C4CCA8CBFFBCB742D6688ADED39B7A02A7D67BD9C13C34ECA", + "migration/test_native114_partial.py": "397A80F3BFFA40615CB3189ACF8878D83A9F59E2A391A5BEB45B4955D24B410E", + "migration/test_native114_player_layout.py": "B0FF8FEAB51EB40693D648A8ADC3F2F29CAB9E9EB073BF6E83F382CA9DD2B49F", + "migration/test_native114_recipes.py": "320EAD45D85E3769542688A03587705B7236A3B9E95E14998F2496243D6C64DC", + "migration/test_native114_refresh.py": "E8F8BF30247F85C6A3BB92774580E8A6070E24E90C1AA88C5EA171C2261316C0", + "migration/test_native114_respawn.py": "3255DBF4ECBEE2334BADF2775E05E8405E7EED0032CEA2282073A2BC55D23EEF", + "migration/test_native114_ticket_networks.py": "88C8D393289902D3C495E1C834C960074E550C30A0734C8B31B89713F91A1763", + "migration/test_native114_transformer_order.py": "217571DE71A61BB3F9C701B7A12F1D768538A68D8A7C744FF93CB5697329ACA0", + "migration/test_oil_transfer.py": "817D82DFEB1F42F7910E16AD2A1B93DD0EC90D284B6691323B9A7DF018A6C379", + "migration/test_pin_dimension_profiles.py": "E57595D1F98B60E0D1492BBB2BA0FD2B96B3E4F7B115F65B33EAB02919ACEFF3", + "migration/test_pipe_only_lifecycle.py": "D579F1B13C8A29DA7A0B3ADF7FF508F532D7D635190DBA29E3D0462DCDF032AE", + "migration/test_progression.py": "2AF0AEE36BDB825BDE8F33924B953B07E4D5E5B74D43C713046D6B9ACF0120FE", + "migration/test_recipe_unlocks.py": "CB0A995D3B160770B62ABD7C523A2EFBE0426925FB522E75F4AD7AF351036E23", + "migration/test_respawn.py": "13A0E096A7CB225D679451316C9C13AF32C13BCAC23F7157D83AA3B28E1AA1B8", + "migration/test_transformer_order.py": "5D7A9D156B86285DCDD0F2F098488AD58E42C0E0313C07C8C821B7B8F426B113", + "migration/trace_connected_progress.py": "865408310A0B79C77033DC54F5C20E60FE643D3584C64F0FC049C582ADF37593", + "migration/transformer_order.py": "16F0D8A7C193B6989B26A61545E3EDBDF725A5010BCF19D8ED31A114B271A2ED", + "profiles/dev-core-apocalyptic.json": "039A28C78013AD323373CC92930A7F676A3DBCBC2AC455D48A9F3C3A6CE70E4C", + "profiles/dev-core-tech-progression.json": "2612CFADCDE424AB8244EDBD4B564BA99EFDCDD22FC2DCE4CD2AAFED164B137E", + "profiles/dev-core.json": "CD93A4205AC737A48B6572530962A36C7F06C316462A8A78B30F9610BF14E3E9", + "profiles/packaged-current.json": "B5966E8B2C43E46AA254E2C0B0E857C0DF0E5F09ED5BEA99AE105D6FC368CCCE", + "profiles/packaged-decoupled.json": "530193E838577757A6348BBFF2B074A745A48D3A35BDCA3CD3F3B491FB5D08CD", + "profiles/packaged-mineralogy.json": "105C6F19C91693F11B1736968D5595F78AD8A2C9A90CB1B46E4E101D26CAF588", + "profiles/packaged-optional-power.json": "D8805E53B79CAB1B879E0E42BE7979DD03CDE8950C35D1A0BFDF26BB092B9AC4", + "profiles/worldgen-advantage.json": "BAE49986C084B25FF262756F5A0819863EF729D12480265FAC4BCC8717DC49DA", + "profiles/worldgen-legacy-import.json": "9061D639C22048D13F72A58497CF725890D086C2D73645BE26AC82F16A40B170", + "profiles/worldgen-mineralogy.json": "981F47E6128777B1AF1DA53DE895D65AFEAD41D1EE0D5CC547B4F727E8DD0F8D", + "profiles/native114/dev-core-apocalyptic.json": "C202B0A0A071D4FFB2EF07A430EC29B829A52C256B6200388CD855FFD11156B3", + "profiles/native114/dev-core-tech-progression.json": "96DFD0F56ED920CCA834F223F0C93FC0F413F1FD4E900BD2E98B41398DB80807", + "profiles/native114/dev-core.json": "1400FAF7008D16AED17758DCE94364EDE33662BCDB385490AEE3788213A75A07", + "profiles/native114/packaged-current.json": "E8B0A815F034323C656178C189F942BD6923614B5AD96DEBD11763084673737A", + "profiles/native114/packaged-decoupled.json": "D4D38A64E32D0A08B48FCFABBDECBD506B599E7DD43DC49CB8CA593F82AA77C8", + "profiles/native114/packaged-mineralogy.json": "B7A9DEA1462A9FEC93E8E52234E05670EB9F0D0422146CE63F24CDBAC280353E", + "profiles/native114/packaged-optional-power.json": "66DE0D5B3A57B1068923A3DDA394F1DA076E3C19FF932183BCAB50DDB5F08721", + "profiles/native114/worldgen-advantage.json": "EB67B53966693C8DD2C67B1EC5A4E75A09ABEEF10E4FB8ABAD5AAB25B846D0F9", + "profiles/native114/worldgen-legacy-import.json": "C0E291E2D6CDE0BE600DF7C2ECC8BAF5DB2AB8791427DBCC785816E20E9B6EF9", + "profiles/native114/worldgen-mineralogy.json": "008006919568339B20B96CC6A6405F9C214061173A1BF153D565B50D9FFA3085", + "src/main/resources/assets/advantageworkstest/works/works-1.13.2.json": "89FF9A4E94477F39CB463ED0D7C1C41588CD4CD3F183EEF8A9197F8A7557E7BC", + "src/main/resources/assets/advantageworkstest/works/works-1.14.4.json": "2BCE16CC7A5B1F673EAB904D9208A9A0E742B04A09143191455998A69377F0DA" + } +} diff --git a/test-rig/migration/native115.py b/test-rig/migration/native115.py new file mode 100644 index 0000000..d14355a --- /dev/null +++ b/test-rig/migration/native115.py @@ -0,0 +1,194 @@ +"""Separate native115 destination pinning and frozen-copy migration witness runner. + +Never builds or launches a source. Historical matrix/connected/actor campaigns remain +separate and their failures are not reclassified by this destination-only runner. +""" + +import argparse +import json +from pathlib import Path +import re +import tomllib +import zipfile + +from matrix import WORLD, compare, copy_fixture, digest, fingerprint, normalized, stage, start_snapshot, write_json + + +RIG = Path(__file__).resolve().parents[1] +LEGACY_POWER = RIG.parents[2] / "PA113/PowerAdvantage" +LAUNCHER = "forge-1.15.2-31.2.57.jar" + + +def native_normalized(value, enchantments=None): + result = normalized(value, enchantments) + # Native 1.14 LockCode.write omits an empty lock. Give only vanilla chest + # roots their canonical unlocked value, retaining strict checks on real locks. + if isinstance(result, dict) and result.get("id") == "minecraft:chest": + result.setdefault("Lock", "") + return result + + +def require_clean_log(path): + text = path.read_text(encoding="utf-8", errors="replace") + failures = [line for line in text.splitlines() if "/ERROR]" in line or any(marker in line for marker in ( + "NoSuchFieldError", "NoSuchMethodError", "Failed to reload data packs", "Encountered an unexpected exception"))] + if failures: + raise AssertionError(f"Native runtime errors in {path}: {failures[:5]}") + + +def output_path(path): + path = path.resolve() + if not path.is_relative_to((RIG / "build").resolve()): + raise ValueError("Native115 outputs must be new paths under test-rig/build") + if path.exists(): + raise FileExistsError(path) + return path + + +def require_disjoint(output, protected): + output, protected = output.resolve(), protected.resolve() + if output.is_relative_to(protected) or protected.is_relative_to(output): + raise ValueError(f"Output overlaps a protected input: {protected}") + + +def jar_identity(path, mod_id, version): + with zipfile.ZipFile(path) as archive: + text = archive.read("META-INF/mods.toml").decode("utf-8") + for fields in tomllib.loads(text).get("mods", []): + if fields.get("modId") == mod_id and fields.get("version") == version: + return + raise ValueError(f"Wrong native mod identity/version: {path}") + + +def pin_target(profile_path, seed, output): + output = output_path(output) + seed = seed.resolve() + require_disjoint(output, seed) + profile = json.loads(profile_path.read_text(encoding="utf-8")) + if (profile.get("minecraftVersion") != "1.15.2" or profile.get("mode") != "packaged" + or profile.get("portStatus") != "PORTED_UNQUALIFIED" + or profile.get("forge", {}).get("version") != "1.15.2-31.2.57"): + raise ValueError("A ported native115 packaged destination profile is required") + if not (seed / LAUNCHER).is_file() or not (seed / "libraries").is_dir(): + raise FileNotFoundError("Supply an installed native Forge 31.2.57 server seed") + jars = [] + for mod in profile["mods"]: + if not re.fullmatch(r"[A-Fa-f0-9]{64}", mod.get("sha256") or ""): + raise ValueError("Pending native115 artifact pin: " + mod["id"]) + path = (RIG / mod["path"]).resolve() + require_disjoint(output, path) + if not mod["version"].endswith(".115021") or path.name != mod["file"] or "deobf" in path.name: + raise ValueError(f"Not a native115 packaged input: {path}") + jar_identity(path, mod["id"], mod["version"]) + actual = digest(path) + if mod.get("sha256") and actual != mod["sha256"].upper(): + raise AssertionError(f"Destination jar hash mismatch: {path}") + entry = {"path": str(path), "sha256": actual, "id": mod["id"], "version": mod["version"]} + for field in ("originPath", "sourceRef"): + if field in mod: + entry[field] = mod[field] + jars.append(entry) + if not {"poweradvantage", "advantageworkstest", "orespawn"}.issubset({j["id"] for j in jars}): + raise ValueError("Missing native Power, OreSpawn or harness input") + write_json(output, {"target115": { + "seed": str(seed), "launcher": LAUNCHER, "launcher_sha256": digest(seed / LAUNCHER), + "jars": jars, "qualificationStatus": "UNQUALIFIED", + "profile": str(profile_path.resolve()), "profileSha256": digest(profile_path), + "legacySourceRoot": str(LEGACY_POWER), + }}) + + +def pinned_profile(manifest_path, key): + entry = json.loads(manifest_path.read_text(encoding="utf-8"))[key] + def resolve(value): + return (manifest_path.parent / value).resolve() + jars = [] + for jar in entry["jars"]: + path = resolve(jar["path"]) + if digest(path) != jar["sha256"].upper(): + raise AssertionError(f"Pinned input changed: {path}") + if key == "target115": + if not jar.get("version", "").endswith(".115021"): + raise ValueError("Destination manifest contains a non-native input") + jar_identity(path, jar["id"], jar["version"]) + jars.append(path) + seed = resolve(entry["seed"]) + if key == "target115": + if entry["launcher"] != LAUNCHER or digest(seed / LAUNCHER) != entry["launcher_sha256"].upper(): + raise AssertionError("Native115 server launcher changed") + identities = [jar["id"] for jar in entry["jars"]] + if len(set(identities)) != len(identities) or not {"poweradvantage", "advantageworkstest", "orespawn"}.issubset(identities): + raise ValueError("Destination must contain unique native Power, OreSpawn and harness inputs") + if not (seed / "libraries").is_dir(): + raise FileNotFoundError("Destination server libraries are missing") + return seed, entry["launcher"], jars + + +def run(args): + source = args.source.resolve() + output = output_path(args.output) + for protected in (source, args.source_manifest, args.target_manifest): + require_disjoint(output, protected) + if not re.fullmatch(r"migration-[A-Za-z0-9_-]+\.json", args.source_snapshot): + raise ValueError("Source snapshot must name an existing snapshot inside the source runtime") + # Explicit archived source manifests resolve against their original PA113 location, + # never against matrix.py's copied PA114 convenience defaults. + pinned_profile(args.source_manifest.resolve(), args.source_key) + target = pinned_profile(args.target_manifest.resolve(), "target115") + require_disjoint(output, target[0]) + before = fingerprint(source) + expected = json.loads(args.source_hashes.read_text(encoding="utf-8")) + if fingerprint(source / WORLD) != expected: + raise AssertionError("Immutable source world does not match its approved fingerprint") + snapshot = source / args.source_snapshot + if not snapshot.is_file() or not (source / "migration-fixture.json").is_file(): + raise FileNotFoundError("Missing retained source snapshot or fixture specification") + try: + launcher = stage(output, target) + copy_fixture(source, output) + results = {} + for label in ("converted", "restarted"): + start_snapshot(output, args.java8, launcher, label) + require_clean_log(output / f"console-{label}.log") + results[label] = compare(snapshot, output / f"migration-{label}.json", native_normalized) + results["idempotence"] = compare(output / "migration-converted.json", output / "migration-restarted.json", native_normalized) + write_json(output / "state-comparison.json", results) + write_json(output / "native115-provenance.json", { + "source": str(source), "sourceManifest": str(args.source_manifest.resolve()), + "sourceManifestSha256": digest(args.source_manifest), "sourceKey": args.source_key, + "sourceSnapshotSha256": digest(snapshot), "sourceHashes": expected, + "targetManifest": str(args.target_manifest.resolve()), "targetManifestSha256": digest(args.target_manifest), + "scope": "frozen-copy-state-and-restart-only", + "platformNormalization": "Only vanilla chest roots treat omitted Lock as the canonical empty string; nonempty locks remain strict.", + }) + return int(any(result["status"] != "PASS" for result in results.values())) + finally: + if fingerprint(source) != before: + raise AssertionError("Protected source runtime changed") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + pin = commands.add_parser("pin-target", help="Pin actual destination jars and an installed server, without opening worlds") + pin.add_argument("--profile", type=Path, default=RIG / "profiles/native115/packaged-current.json") + pin.add_argument("--server-seed", type=Path, required=True) + pin.add_argument("--output", type=Path, required=True) + launch = commands.add_parser("run", help="Convert only a fingerprinted copy; never construct or operate machinery") + launch.add_argument("--source", type=Path, required=True) + launch.add_argument("--source-manifest", type=Path, required=True) + launch.add_argument("--source-key", required=True) + launch.add_argument("--source-hashes", type=Path, required=True) + launch.add_argument("--source-snapshot", default="migration-source.json") + launch.add_argument("--target-manifest", type=Path, required=True) + launch.add_argument("--java8", type=Path, required=True) + launch.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.command == "pin-target": + pin_target(args.profile, args.server_seed, args.output) + return 0 + return run(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_actors.py b/test-rig/migration/native115_actors.py new file mode 100644 index 0000000..ea31136 --- /dev/null +++ b/test-rig/migration/native115_actors.py @@ -0,0 +1,226 @@ +"""Native 1.15 destinations for retained actor/dimension fixtures; no source boots.""" + +import argparse +import copy +import json +from pathlib import Path +import shutil +import socket +import time + +import actors +import dimensions +from matrix import WORLD, Server, copy_fixture, digest, fingerprint, write_json +from native115 import output_path, pinned_profile, require_clean_log, require_disjoint + + +HISTORICAL = ("original110", "early110", "maintained110", "legacy112", "maintained112") +ROUTES = HISTORICAL + ("original110-chained-113", "maintained110-direct-113", "maintained112-direct-113") + + +def baseline(source, kind): + 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()), None) + if label is None: + raise FileNotFoundError(f"No retained {kind} serializer checkpoint in {source}") + capture = dimensions.capture if kind == "dimensions" else actors.capture + return capture(source, label), label + + +def expected_state(value, kind, survival=False, adapter=True): + if kind == "dimensions": + result = dimensions.survival_expected(value) if survival else dimensions.strict_expected(value, adapter) + else: + result = copy.deepcopy(value) + if survival: + for entity in result["entities"].values(): + for key in ("Pos", "Rotation", "Motion"): + entity.pop(key, None) + if not adapter: + result.pop("adapter", None) + result.pop("playerWorldDimension", None) + # Embedded host data is observed at conversion, not rewritten by the server-only player adapter. + return result + + +def retain(runtime, kind, label): + filename = "dimension-fixture.json" if kind == "dimensions" else "actor-fixture.json" + definition = json.loads((runtime / filename).read_text()) + folder = runtime / ("dimension-evidence" if kind == "dimensions" else "actor-evidence") / label + folder.mkdir(parents=True, exist_ok=False) + for source, target in ((runtime / WORLD / "playerdata" / (definition["playerUuid"] + ".dat"), "player.dat"), + (runtime / WORLD / "level.dat", "level.dat")): + shutil.copy2(source, folder / target) + capture = dimensions.capture if kind == "dimensions" else actors.capture + state = capture(runtime, label, folder) + if kind == "dimensions": + state = native_dimension_state(state) + write_json(runtime / (("dimension-semantic-" if kind == "dimensions" else "semantic-") + label + ".json"), state) + return state + + +def native_dimension_state(state): + """1.14+ MobEntity drops Leashed but retains Leash; verified in native 1.15 NBT.""" + result = copy.deepcopy(state) + def visit(value): + if isinstance(value, list): + for entry in value: + visit(entry) + elif isinstance(value, dict): + if value.get("id") in ("minecraft:horse", "minecraft:wolf") and "Leashed" not in value: + value["Leashed"] = int(isinstance(value.get("Leash"), dict)) + for entry in list(value.values()): + visit(entry) + visit(result.get("entities", {})) + return result + + +def command(server, runtime, kind, operation, label): + root, complete = ("advdimensions", "ADVDIMENSIONS") if kind == "dimensions" else ("advactors", "ADVACTORS") + server.send(f"{root} {operation} {label}") + server.wait(f"{complete} COMPLETE {label}", 120) + server.send("save-all flush") + server.wait("Saved the", 45) + return retain(runtime, kind, label) + + +def release(server, runtime, kind, label): + if kind == "dimensions": + # Native leash resolution must happen before the first serializer/save. + server.send(f"advdimensions operate {label}") + server.wait(f"ADVDIMENSIONS COMPLETE {label}", 120) + return None + if kind == "actors": + # Native getChunk() loads FULL terrain but does not grant entity-ticking + # tickets. Ticket only the unchanged historical actor fixture rectangle. + server.send("forceload add 96 -32 223 47") + server.send("say NATIVE115-ACTOR-TICKETS-REQUESTED") + server.wait("NATIVE115-ACTOR-TICKETS-REQUESTED", 30) + return command(server, runtime, kind, "operate", label) + + +def raw(runtime, kind, label): + return json.loads((runtime / (("dimensions-" if kind == "dimensions" else "actors-") + label + ".json")).read_text()) + + +def run_route(source, runtime, target, args): + from actors import boot + before = fingerprint(source) + approved = source / "fixture-hashes.json" + if approved.is_file() and fingerprint(source / WORLD) != json.loads(approved.read_text()): + raise AssertionError(f"Protected fixture differs from approved hashes: {source}") + expected, source_label = baseline(source, args.kind) + launcher = boot(runtime, target, args.java8, args.port) + copy_fixture(source, runtime) + fixture_name = "dimension-fixture.json" if args.kind == "dimensions" else "actor-fixture.json" + shutil.copy2(source / fixture_name, runtime / fixture_name) + if args.kind == "dimensions": + definition = json.loads((runtime / fixture_name).read_text()) + definition["nativeOnLoad"] = True + write_json(runtime / fixture_name, definition) + properties = runtime / "server.properties" + properties.write_text(properties.read_text().replace("allow-nether=false", "allow-nether=true")) + write_json(runtime / "native115-source-provenance.json", { + "source": str(source.resolve()), "sourceSnapshot": source_label, + "sourceFingerprint": before, "sourceWorldFingerprint": fingerprint(source / WORLD), + "targetManifest": str(args.target_manifest.resolve()), "targetManifestSha256": digest(args.target_manifest), + "historicalOracle": "Unchanged actor/dimension semantic, exact item, freeze and tick assertions", + }) + verdict = dimensions.verdict if args.kind == "dimensions" else actors.compare + tick = dimensions.tick_result if args.kind == "dimensions" else actors.tick_result + checks = {} + try: + server = Server(runtime, args.java8, launcher, "conversion") + try: + if args.kind == "dimensions": + release(server, runtime, args.kind, "conversion-release") + time.sleep(4) + converted = command(server, runtime, args.kind, "snapshot", "converted") + live = args.kind == "dimensions" + checks["conversion"] = verdict(expected_state(expected, args.kind, survival=live, adapter=False), converted) + checks["conversion-ticks" if live else "conversion-frozen"] = (tick(raw(runtime, args.kind, "conversion-release"), raw(runtime, args.kind, "converted")) + if live else actors.freeze_result(raw(runtime, args.kind, "converted"))) + adapter = command(server, runtime, args.kind, "read", "adapter") + checks["adapter"] = verdict(expected_state(expected, args.kind, survival=live), adapter) + if not live: + checks["adapter-frozen"] = actors.freeze_result(raw(runtime, args.kind, "adapter")) + backups = actors.backup_state(runtime) + finally: + server.close() + require_clean_log(runtime / "console-conversion.log") + server = Server(runtime, args.java8, launcher, "restart") + try: + if args.kind == "dimensions": + release(server, runtime, args.kind, "restart-load-release") + time.sleep(4) + restarted = command(server, runtime, args.kind, "read", "restarted") + checks["restart"] = verdict(expected_state(expected, args.kind, survival=live), restarted) + if not live: + checks["restart-frozen"] = actors.freeze_result(raw(runtime, args.kind, "restarted")) + checks["idempotence"] = verdict(expected_state(adapter, args.kind, survival=live), restarted) + release(server, runtime, args.kind, "released") + time.sleep(4) + operated = command(server, runtime, args.kind, "read", "runtime") + checks["runtime"] = verdict(expected_state(expected, args.kind, survival=True), operated) + checks["runtime-ticks"] = tick(raw(runtime, args.kind, "released"), raw(runtime, args.kind, "runtime")) + finally: + server.close() + require_clean_log(runtime / "console-restart.log") + server = Server(runtime, args.java8, launcher, "runtime-restart") + try: + release(server, runtime, args.kind, "restart-released") + time.sleep(4) + operated_again = command(server, runtime, args.kind, "read", "runtime-restart") + checks["runtime-restart"] = verdict(expected_state(expected, args.kind, survival=True), operated_again) + checks["restart-ticks"] = tick(raw(runtime, args.kind, "restart-released"), raw(runtime, args.kind, "runtime-restart")) + finally: + server.close() + require_clean_log(runtime / "console-runtime-restart.log") + checks["backup-stability"] = {"status": "PASS" if actors.backup_state(runtime) == backups else "FAIL", "backups": backups} + return checks + finally: + write_json(runtime / "native115-comparison.json", checks) + unchanged = fingerprint(source) == before + write_json(runtime / "native115-source-integrity.json", {"status": "PASS" if unchanged else "FAIL", "unchanged": unchanged}) + if not unchanged: + raise AssertionError(f"Protected source changed: {source}") + + +def run(args): + root = output_path(args.output) + require_disjoint(root, args.fixtures) + target = pinned_profile(args.target_manifest.resolve(), "target115") + require_disjoint(root, target[0]) + with socket.socket() as port: + port.bind(("127.0.0.1", args.port)) + root.mkdir(parents=True) + results = {} + for route in args.routes: + if route not in ROUTES: + raise ValueError(f"Unknown retained route: {route}") + source = args.fixtures / route + try: + results[route] = run_route(source, root / route, target, args) + except Exception as error: + results[route] = {"campaign-error": {"status": "FAIL", "error": repr(error)}} + write_json(root / "results.json", results) + raise + write_json(root / "results.json", results) + print(route + ": " + ", ".join(k + "=" + v["status"] for k, v in results[route].items()), flush=True) + status = "FAIL" if any(check["status"] != "PASS" for route in results.values() for check in route.values()) else "PASS" + write_json(root / "summary.json", {"status": status, "kind": args.kind, "routes": args.routes, + "realClientLogin": "NOT_TESTED", "sourceFixtures": "UNCHANGED", "results": results}) + return int(status != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--kind", choices=("actors", "dimensions"), required=True) + parser.add_argument("--fixtures", type=Path, required=True) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, choices=range(25584, 25588), required=True) + parser.add_argument("--routes", nargs="+", default=list(ROUTES)) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native115_audit_actors.py b/test-rig/migration/native115_audit_actors.py new file mode 100644 index 0000000..f9ba051 --- /dev/null +++ b/test-rig/migration/native115_audit_actors.py @@ -0,0 +1,175 @@ +"""Read-only replay of native actor/dimension evidence and exact item NBT.""" + +import argparse +import json +from pathlib import Path + +import actors +import dimensions +from matrix import WORLD, digest, fingerprint, write_json +from native115 import pinned_profile, require_clean_log +from native115_actors import baseline, expected_state, native_dimension_state, raw + + +def inherited_sources(proofs): + """Check the old receipts' original sources; never approve an old destination.""" + receipts = [] + for proof in proofs: + value = json.loads(proof.read_text()) + if "sourceFingerprint" in value: + records = [{"source": value["source"], "fingerprint": value["sourceFingerprint"]}] + elif "source" in value and "fingerprint" in value: + records = [value] + else: + records = list(value.values()) + if not records: + raise AssertionError("Empty inherited source proof: " + str(proof)) + sources = [] + for record in records: + source = Path(record.get("source", record.get("path", ""))).resolve() + expected = record["fingerprint"] + if not expected or fingerprint(source) != expected: + raise AssertionError("Original source differs from inherited proof: " + str(source)) + sources.append({"source": str(source), "fingerprint": expected}) + receipts.append({"proof": str(proof.resolve()), "proofSha256": digest(proof), "sources": sources}) + return {"status": "PASS", "scope": "Original source custody only; no destination qualification", "receipts": receipts} + + +def persistence_controls(runtime, kind): + """Audit receipts from the bounded live runner; never infer hook-list contents.""" + counts = (2, 2, 1) if kind == "dimensions" else (2, 3, 2) + records = {} + markers = ("WorldPersistenceHooks.lambda$handleWorldDataLoad", + "WorldPersistenceHooks.lambda$handleWorldDataSave", + "Exception reading", "Exception stopping the server", + "An unexpected error occurred trying to execute that command", + "NullPointerException") + for label, required in zip(("conversion", "restart", "runtime-restart"), counts): + path = runtime / ("console-" + label + ".log") + if not path.is_file(): + records[label] = {"status": "FAIL", "reason": "Server log missing"} + continue + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + errors = [{"line": index + 1, "text": line} for index, line in enumerate(lines) + if "/ERROR]" in line or any(marker in line for marker in markers)] + saved = sum("Saved the" in line for line in lines) + started = any("Done (" in line for line in lines) + stopped = any("Stopping server" in line for line in lines) + records[label] = {"status": "PASS" if started and stopped and saved == required and not errors else "FAIL", + "log": str(path), "logSha256": digest(path), "started": started, + "stopMarkerPresent": stopped, "saveReceipts": saved, + "requiredSaveReceipts": required, "errors": errors} + return {"status": "PASS" if all(value["status"] == "PASS" for value in records.values()) else "FAIL", + "boots": records, "liveRunnerBudgetsSeconds": {"startup": 300, "probe": 120, + "saveReceipt": 45, "shutdown": 90}, + "boundsAndExitCode": "Enforced by unchanged native115_actors.command and matrix.Server", + "nativeHookListInspection": "Parent-owned; not inferred from absence of log errors"} + + +def replay(run, kind, persistence=False): + recorded = json.loads((run / "results.json").read_text()) + findings, routes = [], {} + capture = dimensions.capture if kind == "dimensions" else actors.capture + verdict = dimensions.verdict if kind == "dimensions" else actors.compare + tick = dimensions.tick_result if kind == "dimensions" else actors.tick_result + for route, checks in recorded.items(): + runtime = run / route + provenance = json.loads((runtime / "native115-source-provenance.json").read_text()) + source = Path(provenance["source"]) + if fingerprint(source) != provenance["sourceFingerprint"]: + findings.append(route + ": protected source fingerprint changed") + manifest = Path(provenance["targetManifest"]) + if digest(manifest) != provenance["targetManifestSha256"]: + findings.append(route + ": target manifest changed") + pinned_profile(manifest, "target115") + for jar in json.loads((runtime / "input-jars.json").read_text()): + if digest(runtime / "mods" / jar["file"]) != jar["sha256"]: + findings.append(route + ": staged jar changed: " + jar["file"]) + controls = persistence_controls(runtime, kind) if persistence else None + if controls and controls["status"] != "PASS": + findings.append(route + ": bounded persistence controls fail") + if "campaign-error" in checks: + findings.append(route + ": aborted route has no completed behavioral qualification") + for log in runtime.glob("console-*.log"): + try: + require_clean_log(log) + except AssertionError as error: + findings.append(route + ": " + str(error)) + routes[route] = {"status": "FAIL", "recordedCampaignError": checks["campaign-error"], + "semanticReplay": "NOT_POSSIBLE_WITH_INCOMPLETE_SAVED_EVIDENCE"} + if persistence: + routes[route]["persistenceControls"] = controls + continue + expected, _ = baseline(source, kind) + states = {} + live = kind == "dimensions" + prefix = "dimension-semantic-" if live else "semantic-" + evidence = "dimension-evidence" if live else "actor-evidence" + for label in ("converted", "adapter", "restarted", "runtime", "runtime-restart"): + state = capture(runtime, label, runtime / evidence / label) + if live: + state = native_dimension_state(state) + stored = json.loads((runtime / (prefix + label + ".json")).read_text()) + if state != stored: + findings.append(route + "/" + label + ": stored semantic differs from raw evidence") + states[label] = state + fresh = { + "conversion": verdict(expected_state(expected, kind, survival=live, adapter=False), states["converted"]), + "adapter": verdict(expected_state(expected, kind, survival=live), states["adapter"]), + "restart": verdict(expected_state(expected, kind, survival=live), states["restarted"]), + "idempotence": verdict(expected_state(states["adapter"], kind, survival=live), states["restarted"]), + "runtime": verdict(expected_state(expected, kind, survival=True), states["runtime"]), + "runtime-restart": verdict(expected_state(expected, kind, survival=True), states["runtime-restart"]), + "runtime-ticks": tick(raw(runtime, kind, "released"), raw(runtime, kind, "runtime")), + "restart-ticks": tick(raw(runtime, kind, "restart-released"), raw(runtime, kind, "runtime-restart")), + } + if live: + fresh["conversion-ticks"] = tick(raw(runtime, kind, "conversion-release"), raw(runtime, kind, "converted")) + else: + for key, label in (("conversion-frozen", "converted"), ("adapter-frozen", "adapter"), ("restart-frozen", "restarted")): + fresh[key] = actors.freeze_result(raw(runtime, kind, label)) + strict_items = {} + for label in ("converted", "adapter", "restarted", "runtime", "runtime-restart"): + strict = expected_state(expected, kind, survival=live or label.startswith("runtime"), adapter=label != "converted") + issues = dimensions.exact_item_differences(strict, states[label]) + strict_items[label] = {"status": "FAIL" if issues else "PASS", "differences": issues} + if issues: + findings.append(route + "/" + label + ": exact nested item NBT mismatch") + for key, value in fresh.items(): + if value != checks[key]: + findings.append(route + "/" + key + ": recomputed oracle differs from recorded result") + if value["status"] != "PASS": + findings.append(route + "/" + key + ": behavioral assertion fails") + if actors.backup_state(runtime) != checks["backup-stability"]["backups"]: + findings.append(route + ": migrated backup bytes changed") + for label in ("conversion", "restart", "runtime-restart"): + require_clean_log(runtime / ("console-" + label + ".log")) + routes[route] = {"oracles": fresh, "exactItems": strict_items, + "carriedItems": sum(item["Count"] for item in expected["file"].get("Inventory", [])), + "enderItems": sum(item["Count"] for item in expected["file"].get("EnderItems", []))} + if persistence: + routes[route]["persistenceControls"] = controls + return {"status": "FAIL" if findings else "PASS", "kind": kind, "routes": routes, "findings": findings} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path) + parser.add_argument("--kind", choices=("actors", "dimensions")) + parser.add_argument("--source-proofs", type=Path, nargs="+") + parser.add_argument("--persistence-controls", action="store_true") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.output.exists(): + raise FileExistsError(args.output) + if args.source_proofs: + result = inherited_sources(args.source_proofs) + write_json(args.output, result) + print("PASS: " + str(len(result["receipts"])) + " inherited source proofs") + raise SystemExit(0) + if args.run is None or args.kind is None: + parser.error("Replay requires run and kind, or explicit inherited source proofs") + result = replay(args.run.resolve(), args.kind, args.persistence_controls) + write_json(args.output, result) + print(result["status"] + ": " + str(len(result["routes"])) + " routes; " + str(len(result["findings"])) + " integrity findings") + raise SystemExit(int(result["status"] != "PASS")) diff --git a/test-rig/migration/native115_audit_order.py b/test-rig/migration/native115_audit_order.py new file mode 100644 index 0000000..3b97f78 --- /dev/null +++ b/test-rig/migration/native115_audit_order.py @@ -0,0 +1,91 @@ +"""Read-only native coremod order, actor, block and backup evidence replay.""" + +import argparse +import json +from pathlib import Path + +import actors +import transformer_order as historical +from matrix import compare, digest, fingerprint, write_json +from native115 import LEGACY_POWER, native_normalized, require_clean_log +from native115_transformer_order import COREMODS, native_order_evidence + + +def replay(root): + results = json.loads((root / "results.json").read_text()) + sources = json.loads((root / "source-fingerprints.json").read_text()) + build = json.loads((root / "probe-build.json").read_text()) + summary = json.loads((root / "summary.json").read_text()) + findings, evidence, modes = [], {}, {} + patched = root / "coremods-order-test.jar" + if digest(patched) != build["patched_sha256"] or digest(patched) != summary["patchedLibrarySha256"]: + findings.append("Patched observer library changed") + for name, entry in sources.items(): + if fingerprint(Path(entry["source"])) != entry["fingerprint"]: + findings.append(name + ": protected source changed") + for case, recorded in results.items(): + name, mode = case.split("/") + runtime = root / (name + "-" + mode) + historical.assert_staged(runtime) + if digest(runtime / COREMODS) != digest(patched): + findings.append(case + ": staged observer library changed") + expected = json.loads((root / (name + "-mixed-input") / "semantic-source.json").read_text()) + actual = {} + proofs = {} + for label in ("converted", "converted-adapter", "restarted"): + actual[label] = actors.capture(runtime, label, runtime / "ordering-evidence" / label) + if label != "converted": + actual[label].pop("embedded", None) + for label in ("converted", "restarted"): + proof = native_order_evidence(runtime, mode, label) + if proof != recorded[("converted" if label == "converted" else "restart") + "-controls"]["order"]: + findings.append(case + "/" + label + ": order proof differs from raw events/dumps") + proofs[label] = proof + require_clean_log(runtime / ("console-" + label + ".log")) + entries = json.loads((runtime / f"migration-{label}.json").read_text()) + stock = runtime / f"migration-stock-{label}.json" + if json.loads(stock.read_text()) != entries[:-len(historical.METAL_BLOCKS)]: + findings.append(case + "/" + label + ": stock snapshot differs from raw full snapshot") + blocks = LEGACY_POWER / "run-migration-matrix" / ( + "original-cyano-20261005-r1/original110" if name == "original110" else "full-20261005-r1/maintained112") + blocks_result = compare(blocks / "migration-source.json", stock, native_normalized) + metals_result = historical.strict_equal(list(historical.METAL_BLOCKS), + [entry["actualName"] for entry in entries[-len(historical.METAL_BLOCKS):]]) + if blocks_result != recorded["blocks-" + label] or blocks_result["status"] != "PASS": + findings.append(case + "/" + label + ": source block/tile comparison differs/fails") + if metals_result != recorded["metals-" + label] or metals_result["status"] != "PASS": + findings.append(case + "/" + label + ": mixed metal comparison differs/fails") + fresh = { + "conversion": actors.compare({k: v for k, v in expected.items() if k != "adapter"}, actual["converted"]), + "adapter": actors.compare({k: v for k, v in expected.items() if k != "embedded"}, actual["converted-adapter"]), + "restart": actors.compare({k: v for k, v in expected.items() if k != "embedded"}, actual["restarted"]), + "idempotence": historical.strict_equal(actual["converted-adapter"], actual["restarted"]), + "backup-provenance": historical.backup_provenance(root / (name + "-mixed-input"), runtime), + } + for key, value in fresh.items(): + if value != recorded[key] or value["status"] != "PASS": + findings.append(case + "/" + key + ": actor or backup replay differs/fails") + modes.setdefault(name, {})[mode] = {"actors": actual["converted-adapter"], "actorsRestart": actual["restarted"], + "blocks": historical.block_semantics(runtime, "converted"), "blocksRestart": historical.block_semantics(runtime, "restarted")} + evidence[case] = {"oracles": fresh, "proofs": proofs} + cross = {} + for name, values in modes.items(): + reference = next(iter(values.values())) + cross[name] = {mode: historical.strict_equal(reference, value) for mode, value in values.items()} + if cross[name] != summary["crossOrder"][name] or any(result["status"] != "PASS" for result in cross[name].values()): + findings.append(name + ": cross-order actor/block/tile semantics differ") + return {"status": "FAIL" if findings else "PASS", "runtimes": len(results), "findings": findings, + "crossOrder": cross, "evidence": evidence, "backupCaveat": "Individual mod backup original-byte custody depends on callback order"} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.output.exists(): + raise FileExistsError(args.output) + result = replay(args.run.resolve()) + write_json(args.output, result) + print(result["status"] + ": " + str(result["runtimes"]) + " runtimes; " + str(len(result["findings"])) + " integrity findings") + raise SystemExit(int(result["status"] != "PASS")) diff --git a/test-rig/migration/native115_audit_respawn.py b/test-rig/migration/native115_audit_respawn.py new file mode 100644 index 0000000..48d33fb --- /dev/null +++ b/test-rig/migration/native115_audit_respawn.py @@ -0,0 +1,94 @@ +"""Read-only replay of native respawn quantities, source items and saved players.""" + +import argparse +import json +from pathlib import Path + +import nbtlib +from actors import enchantments, semantic +from dimensions import PLAYER_STATE, exact_item_differences +from matrix import WORLD, differences, digest, fingerprint, parse_nbt, write_json +from native115 import pinned_profile, require_clean_log +from native115_actors import baseline +from native115_respawn import native_respawn_verdict +from respawn import spawn_reference + + +def replay(root, target_manifest=None): + results = json.loads((root / "results.json").read_text()) + sources = json.loads((root / "source-fingerprints.json").read_text()) + summary_file = root / "summary.json" + summary = json.loads(summary_file.read_text()) if summary_file.is_file() else None + if summary is None and target_manifest is None: + raise ValueError("Aborted campaigns require an explicit pinned target manifest") + manifest = Path(summary["targetManifest"]) if summary else target_manifest.resolve() + if summary and digest(manifest) != summary["targetManifestSha256"]: + raise AssertionError("Respawn target manifest changed") + pinned_profile(manifest, "target115") + findings, fresh = [], {} + if summary is None: + findings.append("Campaign summary missing: aborted run is not a completed qualification") + for route, source_entry in sources.items(): + source = Path(source_entry["path"]) + if fingerprint(source) != source_entry["fingerprint"]: + findings.append(route + ": protected source changed") + runtime = root / route + expected_file = baseline(source, "dimensions")[0]["file"] + expected_items = {key: expected_file[key] for key in ("Inventory", "EnderItems")} + for jar in json.loads((runtime / "input-jars.json").read_text()): + if digest(runtime / "mods" / jar["file"]) != jar["sha256"]: + findings.append(route + ": staged mod changed") + for label in ("respawn", "respawn-restart"): + log = runtime / ("console-" + label + ".log") + if not log.is_file(): + findings.append(route + "/" + label + ": server log missing") + continue + try: + require_clean_log(log) + except AssertionError as error: + findings.append(route + "/" + label + ": " + str(error)) + for case in (case for case in results if case.startswith(route + "/")): + label = case.split("/")[1] + folder = runtime / "respawn-evidence" / label + record = json.loads((runtime / f"dimensions-{label}.json").read_text()) + registry = enchantments(runtime) + result = native_respawn_verdict(record, registry, spawn_reference(folder / "level.dat")) + before = semantic(parse_nbt(record["respawn"]["before"]), PLAYER_STATE, registry) + source_issues = differences(expected_items, before, "source-inventory") + source_issues.extend(exact_item_differences(expected_items, before, "source-inventory")) + result["differences"].extend(source_issues) + observed = semantic(parse_nbt(record["respawn"]["after"]), PLAYER_STATE, registry) + saved = semantic(nbtlib.load(folder / "player.dat").unpack(), PLAYER_STATE, registry) + save_issues = differences(observed, saved, "saved-player") + exact_item_differences(observed, saved, "saved-player") + result["differences"].extend(save_issues) + if source_issues or save_issues: + result["status"] = "FAIL" + fresh[case] = result + if result != results[case]: + findings.append(case + ": raw oracle result differs from recorded result") + if result["status"] != "PASS": + findings.append(case + ": behavioral assertion fails") + expected_cases = {route + "/" + boot + "-" + keep + "-" + mode + for route in sources for boot in ("respawn", "respawn-restart") + for keep in ("keep", "drop") for mode in ("forced", "bed", "missing")} + missing = sorted(expected_cases - fresh.keys()) + if missing or (summary and len(fresh) != summary["cases"]) or len(fresh) != len(results): + findings.append("Respawn case coverage is incomplete") + return {"status": "FAIL" if findings else "PASS", "cases": len(fresh), "findings": findings, + "expectedCases": len(expected_cases), "missingCases": missing, + "results": fresh, "realClientDeathScreen": "NOT_TESTED", + "targetManifest": str(manifest), "targetManifestSha256": digest(manifest)} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path, required=True) + parser.add_argument("--target-manifest", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.output.exists(): + raise FileExistsError(args.output) + result = replay(args.run.resolve(), args.target_manifest) + write_json(args.output, result) + print(result["status"] + ": " + str(result["cases"]) + " cases; " + str(len(result["findings"])) + " integrity findings") + raise SystemExit(int(result["status"] != "PASS")) diff --git a/test-rig/migration/native115_broad.py b/test-rig/migration/native115_broad.py new file mode 100644 index 0000000..4f2c460 --- /dev/null +++ b/test-rig/migration/native115_broad.py @@ -0,0 +1,318 @@ +"""Bounded native115 walkable-bay sweep with initial, movement and restart oracles. + +Production jars come only from a new immutable manifest. Historical station +definitions and failed broad runs remain unchanged. No source world is launched. +""" + +import argparse +from collections import Counter +import json +import math +from pathlib import Path +import shutil +import struct +import time +import traceback + +import nbtlib +from matrix import Server, digest, plain, stage, write_json +from native115 import RIG, output_path, pinned_profile, require_disjoint +from native115_lifecycle import require_native_boot, verify_staged + + +DIRECTIONS = ("down", "up", "north", "south", "west", "east") +REAR = ((0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1), (1, 0, 0), (-1, 0, 0)) +DRILL_BAYS = ("S-05", "E-06") + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def drill_cases(station): + result = [] + for assertion in station["assertions"]: + if assertion.get("block") not in ("steamadvantage:steam_drill", "electricadvantage:electric_drill"): + continue + direction = assertion.get("meta", DIRECTIONS.index(assertion.get("expectedText", "down"))) + origin = tuple(a + b for a, b in zip(station["origin"], assertion["pos"])) + rear = tuple(a + b for a, b in zip(origin, REAR[direction])) + result.append(dict(id=station["id"] + ":" + assertion["id"], origin=origin, rear=rear, + facing=DIRECTIONS[direction], block=assertion["block"])) + if len(result) != 6 or {r["facing"] for r in result} != set(DIRECTIONS): + raise AssertionError("The original six-facing bay definition changed") + return result + + +def nbt(row): + return plain(nbtlib.parse_nbt(row["nbt"])) if "nbt" in row else {} + + +def drill_state(station, document, *, initial=False): + rows = {tuple(row["pos"]): row for row in document["snapshot"]} + if len(rows) != len(document["snapshot"]): + raise AssertionError("Duplicate snapshot positions") + cases = drill_cases(station) + stock = Counter() + for row in rows.values(): + for stack in nbt(row).get("Items", []): + marker = stack.get("tag", {}).get("AdvBayStock") + if marker: + if stack.get("id") != "minecraft:diamond": + raise AssertionError("Inventory witness item identity changed") + stock[marker] += int(stack["Count"]) + expected_positions, result = set(), {} + for case in cases: + candidates = [rows[pos] for pos in (case["origin"], case["rear"]) + if pos in rows and rows[pos]["block"] == case["block"]] + if len(candidates) != 1: + raise AssertionError(case["id"] + ": missing or duplicate drill in declared cells") + row = candidates[0] + pos = tuple(row["pos"]) + if initial and pos != case["origin"]: + raise AssertionError(case["id"] + ": initial placement moved before activation") + if initial: + track = "steamadvantage:steam_track" if station["id"] == "S-05" else "electricadvantage:electric_track" + if rows.get(case["rear"], {}).get("block") != track: + raise AssertionError(case["id"] + ": initial declared rear track is missing") + if "facing=" + case["facing"] not in row["state"]: + raise AssertionError(case["id"] + ": block facing changed") + data = nbt(row) + if data.get("ForgeData", {}).get("AdvBayWitness") != case["id"]: + raise AssertionError(case["id"] + ": persistent moved tile witness lost/replaced") + if tuple(int(data[axis]) for axis in ("x", "y", "z")) != pos: + raise AssertionError(case["id"] + ": tile coordinates do not match its block") + if stock[case["id"]] != 7: + raise AssertionError(case["id"] + ": inventory stock lost or duplicated") + energy = data.get("Energy", []) + if len(energy) != 1: + raise AssertionError(case["id"] + ": energy witness missing") + value = struct.unpack("!f", struct.pack("!I", int(energy[0]) & 0xffffffff))[0] + capacity = 50 if station["id"] == "S-05" else 500 + if not math.isfinite(value) or not 0 <= value <= capacity: + raise AssertionError(case["id"] + ": invalid energy") + expected_positions.add(pos) + result[case["id"]] = dict(pos=list(pos), facing=case["facing"], block=row["block"], + tile=data["id"], witness=case["id"], stock=7, + energyBits=[int(e) for e in energy], energy=value, + moved=pos == case["rear"]) + actual_positions = {pos for pos, row in rows.items() + if row["block"] in ("steamadvantage:steam_drill", "electricadvantage:electric_drill")} + if actual_positions != expected_positions or set(stock) != {case["id"] for case in cases}: + raise AssertionError("Extra/off-track drill or inventory witness in bay") + return result + + +def operating_state(initial, operated): + if initial.keys() != operated.keys() or len(initial) != 12: + raise AssertionError("Twelve unique drill witnesses are required") + for key in initial: + for field in ("facing", "block", "tile", "witness", "stock"): + if initial[key][field] != operated[key][field]: + raise AssertionError(key + ": movement changed " + field) + # These two original connected lanes are the Q-RIG-001 reproduction. + for key in ("S-05:down-facing", "E-06:up-facing"): + if not operated[key]["moved"]: + raise AssertionError(key + ": the broad run did not exercise actual track movement") + return dict(status="PASS", drills=12, moved=sum(v["moved"] for v in operated.values())) + + +def restarted_state(before, after): + if before != after: + changed = [key for key in set(before) | set(after) if before.get(key) != after.get(key)] + raise AssertionError("Restart changed position, identity, facing, stock or energy: " + str(changed)) + return dict(status="PASS", drills=len(after), exactEnergy=True) + + +def assertions_state(records, stations): + if set(records) != {station["id"] for station in stations}: + raise AssertionError("The broad sweep omitted a bay") + counts = Counter() + manual = [] + for key, document in records.items(): + if document["station"] != key or document["minecraft"] != "1.15.2": + raise AssertionError("Wrong bay/target report") + if document["status"] not in ("PASS", "MANUAL_REQUIRED"): + raise AssertionError(key + ": " + document["status"]) + expected = next(s for s in stations if s["id"] == key) + if (len(document["assertions"]) != len(expected["assertions"]) + or {a["id"] for a in document["assertions"]} != {a["id"] for a in expected["assertions"]}): + raise AssertionError(key + ": assertion set changed") + for assertion in document["assertions"]: + counts[assertion["status"]] += 1 + if assertion["status"] not in ("PASS", "SKIP"): + raise AssertionError(key + "/" + assertion["id"] + ": " + assertion["status"]) + if assertion["status"] == "SKIP": + spec = next(a for a in expected["assertions"] if a["id"] == assertion["id"]) + if not set(spec.get("requiredMods", [])) - set(document["mods"]): + raise AssertionError(key + "/" + assertion["id"] + ": unexplained skip") + if document["manualChecks"]: + manual.append(key) + return dict(status="PASS", assertions=dict(counts), manualRequired=manual) + + +def loaded_mods(records, expected): + for key, record in records.items(): + if record["mods"] != expected: + raise AssertionError(key + ": loaded mod identities/versions differ from the pinned profile") + + +def authorize_broad(runtime): + # matrix.stage is shared with frozen conversion campaigns. A live works + # campaign must explicitly opt out of MigrationProbe's tile-tick freeze. + for name in ("advantage-works.allow", "migration-operate.allow"): + (runtime / name).touch() + + +def configure_broad(runtime, port): + # Match the walkable WorksRunner rather than matrix.stage's migration-only + # defaults. Clearing a freshly generated village can race native POI tasks. + overrides = {"server-port": str(port), "generator-settings": "", "generate-structures": "false", + "level-seed": "advantage-works-1.15.2-v1", "difficulty": "0", "gamemode": "creative", + "allow-flight": "true", "enable-command-block": "true", "force-gamemode": "true", + "spawn-animals": "false", "spawn-monsters": "false", "spawn-npcs": "false", "pvp": "false"} + path = runtime / "server.properties" + lines = [line for line in path.read_text().splitlines() if line.partition("=")[0] not in overrides] + path.write_text("\n".join(lines) + "\n" + "".join(f"{key}={value}\n" for key, value in overrides.items())) + + +class BroadServer(Server): + def command(self, command, token): + self.send(command) + self.wait(token, 150) + + def checkpoint(self, station, label): + self.command(f"advworks checkpoint {station} {label}", "Checkpoint written for " + station) + return read_json(Path(self.log.name).parent / "advantage-works-results" / f"{station}-checkpoint-{label}.json") + + def check_all(self, stations, phase): + self.command("advworks check all", "Advantage Works check:") + directory = Path(self.log.name).parent / "advantage-works-results" + records = {s["id"]: read_json(directory / (s["id"] + "-latest.json")) for s in stations} + write_json(directory / (phase + "-all-stations.json"), records) + return records + + +def run_profile(args, profile, output, name, port, stations): + if name == "packaged-decoupled": + profile = (profile[0], profile[1], [p for p in profile[2] if not p.name.startswith("BaseMetals-")]) + runtime = output / name + launcher = stage(runtime, profile) + configure_broad(runtime, port) + authorize_broad(runtime) + result = dict(status="FAIL", profile=name, port=port, phases={}) + pins = read_json(args.target_manifest)["target115"]["jars"] + expected_mods = {pin["id"]: pin["version"] for pin in pins + if name != "packaged-decoupled" or pin["id"] != "basemetals"} + expected_mods.update(minecraft="1.15.2", forge="31.2.57") + result["expectedMods"] = expected_mods + server = None + try: + verify_staged(runtime, profile) + server = BroadServer(runtime, args.java8, launcher, "first") + server.command("advworks build all", "Advantage Works built 21 station(s)") + # Named finite stock and tile data are commissioning witnesses, not repairs + # performed after operation or restart. Every destination is unchanged later. + for station in stations: + if station["id"] not in DRILL_BAYS: + continue + for case in drill_cases(station): + pos = " ".join(map(str, case["origin"])) + marker = json.dumps(case["id"]) + tag = ('{ForgeData:{AdvBayWitness:' + marker + '},Items:[{Slot:0b,id:"minecraft:diamond",' + 'Count:7b,tag:{AdvBayStock:' + marker + '}}]}') + server.command(f"data merge block {pos} {tag}", "Modified block data of") + initial = {} + for station in stations: + if station["id"] in DRILL_BAYS: + initial.update(drill_state(station, server.checkpoint(station["id"], "initial"), initial=True)) + result["phases"]["initial"] = dict(status="PASS", drills=initial) + write_json(runtime / "broad-results.json", result) + for station in stations: + sid = station["id"] + server.command("advworks start " + sid, "Started " + sid) + time.sleep(max(60, args.run_seconds) if sid == "W-03" else args.run_seconds) + server.checkpoint(sid, "operated") + server.command("advworks stop " + sid, "Stopped " + sid) + first = server.check_all(stations, "first") + loaded_mods(first, expected_mods) + result["phases"]["first"] = assertions_state(first, stations) + before = {} + for station in stations: + if station["id"] in DRILL_BAYS: + before.update(drill_state(station, first[station["id"]])) + result["phases"]["operation"] = operating_state(initial, before) + result["beforeRestart"] = before + server.close() + server = None + require_native_boot(runtime / "console-first.log") + verify_staged(runtime, profile) + server = BroadServer(runtime, args.java8, launcher, "restarted") + # No start, reset, data merge, replacement, or refill on the second boot. + time.sleep(args.run_seconds) + second = server.check_all(stations, "restarted") + loaded_mods(second, expected_mods) + result["phases"]["restarted"] = assertions_state(second, stations) + after = {} + for station in stations: + if station["id"] in DRILL_BAYS: + after.update(drill_state(station, second[station["id"]])) + result["afterRestart"] = after + result["phases"]["persistence"] = restarted_state(before, after) + server.close() + server = None + require_native_boot(runtime / "console-restarted.log") + verify_staged(runtime, profile) + result["status"] = "PASS" + except Exception: + result["error"] = traceback.format_exc() + finally: + if server is not None: + try: + server.close() + except Exception: + result["shutdownError"] = traceback.format_exc() + write_json(runtime / "broad-results.json", result) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--port", type=int, default=25588) + parser.add_argument("--run-seconds", type=int, default=8) + parser.add_argument("--profile", choices=("both", "packaged-current", "packaged-decoupled"), default="both") + args = parser.parse_args() + if not 1 <= args.run_seconds <= 60 or not 1024 <= args.port < 65535: + parser.error("Bounded run duration and unprivileged consecutive ports required") + output = output_path(args.output) + profile = pinned_profile(args.target_manifest, "target115") + target_pins = read_json(args.target_manifest)["target115"]["jars"] + required = {"poweradvantage", "steamadvantage", "electricadvantage", "advantageworkstest", "basemetals", "orespawn"} + if {pin["id"] for pin in target_pins} != required: + raise AssertionError("Supply the complete current-profile manifest; the runner derives its decoupled subset") + for protected in (args.target_manifest, profile[0], *profile[2]): + require_disjoint(output, protected) + stations_path = RIG / "src/main/resources/assets/advantageworkstest/works/works-1.13.2.json" + stations = read_json(stations_path)["stations"] + if len(stations) != 21: + raise AssertionError("The complete 21-bay specification is required") + output.mkdir(parents=True) + shutil.copy2(Path(__file__), output / "runner-source.py") + source_hash = digest(output / "runner-source.py") + names = ("packaged-current", "packaged-decoupled") if args.profile == "both" else (args.profile,) + results = {} + for index, name in enumerate(names): + results[name] = run_profile(args, profile, output, name, args.port + index, stations) + write_json(output / "native115-broad-results.json", dict( + status="PASS" if all(r["status"] == "PASS" for r in results.values()) else "FAIL", + targetManifest=str(args.target_manifest.resolve()), targetManifestSha256=digest(args.target_manifest), + runnerSha256=source_hash, stationDefinitionSha256=digest(stations_path), profiles=results)) + print(name, results[name]["status"], flush=True) + return int(any(r["status"] != "PASS" for r in results.values())) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_campaign.py b/test-rig/migration/native115_campaign.py new file mode 100644 index 0000000..9f4efe9 --- /dev/null +++ b/test-rig/migration/native115_campaign.py @@ -0,0 +1,317 @@ +"""Preserve the exact 114 campaign inputs and add its accepted native destinations. + +Preparation reads canonical evidence and fingerprints source worlds; it never +launches them. Runs always create new 115 destinations and reuse native115's +unchanged frozen/connected state and operating algorithms. +""" + +import argparse +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch +import traceback + +from matrix import WORLD, digest, fingerprint, write_json +import native115 +import native115_connected +import native114 +import native114_connected +from native115_lifecycle import read_json +from native115_refresh import snapshot_name + + +ACCEPTED_POWER114 = "ef08e5cd134dc75bd73518bf4c90a6711e67280f" +HISTORICAL = ("early110", "legacy112", "maintained110", "maintained112", "original110") +SOURCE113 = tuple(name + "-direct-113" for name in HISTORICAL) + tuple( + name + "-chained-113" for name in ("early110", "maintained110", "original110")) +FROZEN_IDS = tuple("legacy/" + name for name in HISTORICAL + SOURCE113) +CONNECTED_IDS = tuple("legacy/" + name for name in HISTORICAL) +REQUIRED = { + "frozen": set(FROZEN_IDS) | {"native114/full"}, + "connected": set(CONNECTED_IDS) | {"native114/" + name for name in HISTORICAL}, +} + + +def approved_file(path, checksum): + path = Path(path).resolve() + if digest(path) != checksum.upper(): + raise AssertionError("Archived evidence/input changed: " + str(path)) + return path + + +def source_row(output, kind, route_id, source, manifest, key, snapshot, fixture_key=None): + source, manifest = source.resolve(), manifest.resolve() + native115.require_disjoint(output, source) + native115.require_disjoint(output, manifest) + native115.pinned_profile(manifest, key) + before = fingerprint(source) + hashes = fingerprint(source / WORLD) + if not hashes: + raise AssertionError("Missing canonical source world: " + str(source)) + filename = "migration-" + snapshot + ".json" if kind == "connected" else snapshot + if not (source / filename).is_file() or not (source / "migration-fixture.json").is_file(): + raise FileNotFoundError("Missing retained source serializer checkpoint: " + str(source)) + hash_file = output / "source-hashes" / (kind + "-" + route_id.replace("/", "-") + ".json") + write_json(hash_file, hashes) + if fingerprint(source) != before: + raise AssertionError("Protected canonical source changed during preparation") + return dict(id=route_id, kind=kind, source=str(source), sourceManifest=str(manifest), sourceKey=key, + sourceManifestSha256=digest(manifest), sourceHashes=str(hash_file), sourceHashesSha256=digest(hash_file), + sourceSnapshot=snapshot, sourceSnapshotSha256=digest(source / filename), sourceFixtureKey=fixture_key) + + +def require_routes(document): + if document.get("format") != 1 or document.get("acceptedPower114") != ACCEPTED_POWER114: + raise AssertionError("Campaign must retain the explicitly accepted merged Power 114 identity") + rows = document["routes"] + for kind, expected in REQUIRED.items(): + selected = [row["id"] for row in rows if row.get("kind") == kind] + if len(selected) != len(expected) or set(selected) != expected: + raise AssertionError("Incomplete/duplicated " + kind + " routes: all historical and native114 cases are required") + if len(rows) != sum(map(len, REQUIRED.values())): + raise AssertionError("Unexpected campaign route kind") + + +def prepare(args): + output = native115.output_path(args.output) + for protected in (args.frozen_evidence, args.connected_evidence): + native115.require_disjoint(output, protected) + frozen, connected = read_json(args.frozen_evidence), read_json(args.connected_evidence) + for report, count in ((frozen, 13), (connected, 5)): + if report.get("status") != "PASS" or len(report["routes"]) != count or any( + row.get("status") != "PASS" for row in report["routes"]): + raise AssertionError("Select the complete accepted 114 evidence, not an incomplete/passing subset") + output.mkdir(parents=True, exist_ok=False) + document = dict(format=1, acceptedPower114=ACCEPTED_POWER114, qualificationStatus="UNQUALIFIED", routes=[], + frozenEvidence=str(args.frozen_evidence.resolve()), frozenEvidenceSha256=digest(args.frozen_evidence), + connectedEvidence=str(args.connected_evidence.resolve()), connectedEvidenceSha256=digest(args.connected_evidence)) + for row in frozen["routes"]: + runtime = Path(row["runtime"]) + old = runtime / "native114-provenance.json" + if row.get("provenanceSha256"): + approved_file(old, row["provenanceSha256"]) + if read_json(runtime / "state-comparison.json") != row["comparison"]: + raise AssertionError("Archived frozen comparison differs from the selected final report") + provenance = read_json(old) + source = Path(provenance["source"]) + name = source.name + manifest = approved_file(provenance["sourceManifest"], provenance["sourceManifestSha256"]) + selected = snapshot_name(source, provenance["sourceSnapshotSha256"]) + if fingerprint(source / WORLD) != provenance["sourceHashes"]: + raise AssertionError("Original frozen source fingerprint changed") + legacy = source_row(output, "frozen", "legacy/" + name, source, manifest, provenance["sourceKey"], selected) + document["routes"].append(legacy) + if name == "original110-chained-113": + document["routes"].append(dict(id="native114/full", kind="frozen", native114SourceRecipe=legacy, + source114Manifest=provenance["targetManifest"], source114ManifestSha256=provenance["targetManifestSha256"], + sourceStatus="REQUIRES_DISPOSABLE_114_GENERATION")) + for row in connected["routes"]: + old = approved_file(row["result"], row["sha256"]) if "result" in row and "sha256" in row else Path(row["result"]) + # Refresh reports record result paths without a second digest. The result + # itself still pins every input and is tied to the selected archive above. + provenance = read_json(old) + if (provenance.get("status") != "PASS" or provenance.get("sourcePreservation", {}).get("status") != "PASS" + or provenance.get("sourceRuntimeBefore") != provenance.get("sourceRuntimeAfter")): + raise AssertionError("Native114 connected operation/source preservation did not pass") + source = Path(provenance["source"]) + name = provenance["sourceKey"] + manifest = approved_file(provenance["sourceManifest"], provenance["sourceManifestSha256"]) + approved_file(provenance["sourceHashes"], provenance["sourceHashesSha256"]) + if fingerprint(source / WORLD) != read_json(Path(provenance["sourceHashes"])): + raise AssertionError("Original connected source fingerprint changed") + legacy = source_row(output, "connected", "legacy/" + name, source, manifest, name, provenance["sourceCheckpoint"]) + document["routes"].append(legacy) + document["routes"].append(dict(id="native114/" + name, kind="connected", native114SourceRecipe=legacy, + source114Manifest=provenance["targetManifest"], source114ManifestSha256=provenance["targetManifestSha256"], + sourceStatus="REQUIRES_DISPOSABLE_114_GENERATION")) + require_routes(document) + write_json(output / "native115-upgrade-routes.json", document) + return 0 + + +def require_source114_receipt(row): + if not row["id"].startswith("native114/"): + return + if row.get("sourceStatus") != "GENERATED_DISPOSABLE_114_VERIFIED": + raise AssertionError("A new fingerprint of an unverified old 114 destination is not approval") + receipt_path = approved_file(row["source114Receipt"], row["source114ReceiptSha256"]) + receipt = read_json(receipt_path) + source = Path(row["source"]) + if (receipt.get("status") != "PASS" or receipt.get("acceptedPower114") != ACCEPTED_POWER114 + or receipt.get("sourceRuntime") != str(source.resolve()) + or receipt.get("runtimeFingerprint") != fingerprint(source) + or receipt.get("worldFingerprint") != fingerprint(source / WORLD)): + raise AssertionError("Recorded disposable114 source receipt/world fingerprint changed") + approved_file(receipt["target114Manifest"], receipt["target114ManifestSha256"]) + native114.pinned_profile(Path(receipt["target114Manifest"]), "target114") + if row["kind"] == "frozen" and (receipt.get("blocks"), receipt.get("tiles")) != (355, 332): + raise AssertionError("Native114 source must retain the full 355-block/332-tile fixture") + if row["kind"] == "connected" and set(receipt.get("connectedCases", [])) != { + "C-CONVEYOR", "C-CRUSHER", "C-EDRILL", "C-ELECTRIC", "C-SDRILL", "C-STEAM", "battery"}: + raise AssertionError("Native114 connected source lacks a required lane/drill/battery") + + +def generate114(args): + document = read_json(args.routes) + require_routes(document) + output = native115.output_path(args.output) + native115.require_disjoint(output, args.routes) + for row in document["routes"]: + if not row["id"].startswith("native114/"): + continue + recipe = row["native114SourceRecipe"] + for protected in (recipe["source"], recipe["sourceManifest"], recipe["sourceHashes"], row["source114Manifest"]): + native115.require_disjoint(output, Path(protected)) + seed, _, jars = native114.pinned_profile(Path(row["source114Manifest"]), "target114") + for protected in (seed, *jars): + native115.require_disjoint(output, protected) + output.mkdir(parents=True, exist_ok=False) + for row in document["routes"]: + if not row["id"].startswith("native114/"): + continue + if row.get("sourceStatus") != "REQUIRES_DISPOSABLE_114_GENERATION": + raise AssertionError("Do not replay or replace an existing source-generation receipt") + recipe = row["native114SourceRecipe"] + manifest = approved_file(row["source114Manifest"], row["source114ManifestSha256"]) + native114.pinned_profile(manifest, "target114") + source_manifest = approved_file(recipe["sourceManifest"], recipe["sourceManifestSha256"]) + source_hashes = approved_file(recipe["sourceHashes"], recipe["sourceHashesSha256"]) + source = Path(recipe["source"]) + filename = "migration-" + recipe["sourceSnapshot"] + ".json" if row["kind"] == "connected" else recipe["sourceSnapshot"] + approved_file(source / filename, recipe["sourceSnapshotSha256"]) + destination = output / row["id"].replace("/", "-") + settings = Namespace(source=source, source_manifest=source_manifest, source_key=recipe["sourceKey"], + source_hashes=source_hashes, source_snapshot=recipe["sourceSnapshot"], + source_checkpoint=recipe["sourceSnapshot"], target_manifest=manifest, + java8=args.java8, output=destination / "runtime", ticks=512, port=args.port) + receipt = dict(status="RUNNING", acceptedPower114=ACCEPTED_POWER114, + method="new disposable copy converted/operated under exact accepted archived114 jars; no canonical launch", + target114Manifest=str(manifest), target114ManifestSha256=digest(manifest), + originalSource=str(source), originalSourceHashesSha256=digest(source_hashes)) + destination.mkdir(parents=True, exist_ok=False) + receipt_path = destination / "source114-receipt.json" + try: + if row["kind"] == "frozen": + definition = read_json(source / recipe["sourceSnapshot"]) + counts = (len(definition), sum("nbtBase64" in entry for entry in definition)) + if counts != (355, 332): + raise AssertionError("Approved source is not the full 355-block/332-tile fixture") + stage = native114.stage + def stage_at_port(runtime, profile): + launcher = stage(runtime, profile) + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() + if not line.startswith(("server-port=", "generator-settings="))] + properties.write_text("\n".join(lines) + f"\nserver-port={args.port}\ngenerator-settings=\n", encoding="utf-8") + return launcher + with patch.object(native114, "stage", stage_at_port): + code = native114.run(settings) + runtime, snapshot, fixture_key = settings.output, "migration-restarted.json", None + report = read_json(runtime / "state-comparison.json") + if any(report.get(gate, {}).get("status") != "PASS" for gate in ("converted", "restarted", "idempotence")): + raise AssertionError("Disposable114 source differs from the approved frozen semantics") + receipt.update(blocks=355, tiles=332, semanticComparison=report) + else: + code = native114_connected.run(settings) + runtime, snapshot = settings.output / "target114", "restarted-worked" + fixture_key = read_json(runtime / "connected-fixture.json")["sourceProfile"] + report = read_json(settings.output / "native114-connected-results.json") + if report.get("sourcePreservation", {}).get("status") != "PASS" or any( + value.get("status") != "PASS" for value in report["results"].values()): + raise AssertionError("Disposable114 source failed preserved stocks or measured operation") + receipt["connectedCases"] = sorted(report["sourceState"]["cases"]) + receipt["operatingComparison"] = report["results"] + if code: + raise AssertionError("Native114 source-generation protocol failed") + receipt.update(status="PASS", sourceRuntime=str(runtime.resolve()), + runtimeFingerprint=fingerprint(runtime), worldFingerprint=fingerprint(runtime / WORLD)) + write_json(receipt_path, receipt) + pinned = source_row(output / "source-records", row["kind"], row["id"], runtime, manifest, + "target114", snapshot, fixture_key) + row.update(pinned, sourceStatus="GENERATED_DISPOSABLE_114_VERIFIED", source114Receipt=str(receipt_path.resolve()), + source114ReceiptSha256=digest(receipt_path)) + require_source114_receipt(row) + except BaseException as error: + receipt.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + write_json(receipt_path, receipt) + raise + finally: + write_json(output / "native115-upgrade-routes.json", document) + return 0 + + +def run(args): + document = read_json(args.routes) + require_routes(document) + output = native115.output_path(args.output) + native115.require_disjoint(output, args.routes) + native115.require_disjoint(output, args.target_manifest) + seed, _, jars = native115.pinned_profile(args.target_manifest.resolve(), "target115") + for protected in (seed, *jars): + native115.require_disjoint(output, protected) + rows = [row for row in document["routes"] if row["kind"] == args.kind] + for row in rows: + require_source114_receipt(row) + for protected in (row["source"], row["sourceManifest"], row["sourceHashes"]): + native115.require_disjoint(output, Path(protected)) + report = dict(status="RUNNING", kind=args.kind, routesManifest=str(args.routes.resolve()), + routesManifestSha256=digest(args.routes), targetManifest=str(args.target_manifest.resolve()), + targetManifestSha256=digest(args.target_manifest), routes=[]) + output.mkdir(parents=True, exist_ok=False) + for index, row in enumerate(rows): + outcome = dict(id=row["id"], status="RUNNING") + report["routes"].append(outcome) + try: + manifest = approved_file(row["sourceManifest"], row["sourceManifestSha256"]) + hashes = approved_file(row["sourceHashes"], row["sourceHashesSha256"]) + source = Path(row["source"]) + filename = "migration-" + row["sourceSnapshot"] + ".json" if args.kind == "connected" else row["sourceSnapshot"] + approved_file(source / filename, row["sourceSnapshotSha256"]) + settings = Namespace(source=source, source_manifest=manifest, source_key=row["sourceKey"], + source_hashes=hashes, source_snapshot=row["sourceSnapshot"], source_checkpoint=row["sourceSnapshot"], + source_fixture_key=row.get("sourceFixtureKey"), target_manifest=args.target_manifest, + java8=args.java8, output=output / f"route-{index:02}", ticks=512, port=args.port) + if args.kind == "connected": + code = native115_connected.run(settings) + else: + stage = native115.stage + def stage_at_port(runtime, profile): + launcher = stage(runtime, profile) + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() + if not line.startswith(("server-port=", "generator-settings="))] + properties.write_text("\n".join(lines) + f"\nserver-port={args.port}\ngenerator-settings=\n", encoding="utf-8") + return launcher + with patch.object(native115, "stage", stage_at_port): + code = native115.run(settings) + outcome.update(status="PASS" if code == 0 else "FAIL", runtime=str(settings.output)) + except Exception as error: + outcome.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + write_json(output / "native115-campaign-results.json", report) + report["status"] = "PASS" if len(report["routes"]) == len(REQUIRED[args.kind]) and all( + row["status"] == "PASS" for row in report["routes"]) else "FAIL" + write_json(output / "native115-campaign-results.json", report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + pin = commands.add_parser("prepare") + pin.add_argument("--frozen-evidence", type=Path, required=True) + pin.add_argument("--connected-evidence", type=Path, required=True) + pin.add_argument("--output", type=Path, required=True) + source114 = commands.add_parser("generate114", help="Generate and shut down new disposable accepted114 sources; never launch canonical saves") + source114.add_argument("--routes", type=Path, required=True) + source114.add_argument("--java8", type=Path, required=True) + source114.add_argument("--output", type=Path, required=True) + source114.add_argument("--port", type=int, default=25595) + launch = commands.add_parser("run") + launch.add_argument("kind", choices=("frozen", "connected")) + launch.add_argument("--routes", type=Path, required=True) + launch.add_argument("--target-manifest", type=Path, required=True) + launch.add_argument("--java8", type=Path, required=True) + launch.add_argument("--output", type=Path, required=True) + launch.add_argument("--port", type=int, default=25595) + arguments = parser.parse_args() + raise SystemExit({"prepare": prepare, "generate114": generate114, "run": run}[arguments.command](arguments)) diff --git a/test-rig/migration/native115_connected.py b/test-rig/migration/native115_connected.py new file mode 100644 index 0000000..e5eac42 --- /dev/null +++ b/test-rig/migration/native115_connected.py @@ -0,0 +1,258 @@ +"""Resume prebuilt, pinned connected source copies in two genuine native115 boots. + +Only staging and process adapters are injected into connected.prepare/resume; +the connected state, stock and operation oracles are reused unchanged. +""" + +import argparse +import json +from pathlib import Path +import re +import socket +from unittest.mock import patch + +import connected +from matrix import WORLD, compare, digest, fingerprint, write_json +from native115 import (LAUNCHER, native_normalized, output_path, pinned_profile, + require_clean_log, require_disjoint) + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def require_command(command): + if command in ("stop", "save-all", "save-all flush"): + return + if re.fullmatch(r"advconnected snapshot [A-Za-z0-9_-]+", command): + return + match = re.fullmatch(r"advconnected step ([0-9]+) [A-Za-z0-9_-]+", command) + if match and 1 <= int(match[1]) <= 4000: + return + raise AssertionError("Forbidden native connected mutation: " + command) + + +def require_document(document, definition, *, destination): + expected = {entry["id"]: entry for entry in definition["blocks"]} + rows = document["snapshot"] + actual = {entry["id"]: entry for entry in rows} + if len(expected) != len(definition["blocks"]) or len(actual) != len(rows) or actual.keys() != expected.keys(): + raise AssertionError("Connected witness IDs/count changed") + for key, entry in actual.items(): + for field in ("station", "kind", "name", "x", "y", "z"): + if entry[field] != expected[key][field]: + raise AssertionError(f"Connected witness changed: {key}.{field}") + if not document.get("held"): + raise AssertionError("Connected snapshot is not frozen") + if destination and document.get("buildCallsThisProcess") != 0: + raise AssertionError("Destination construction is forbidden") + verdict = connected.state(document) + if verdict["status"] != "PASS": + raise AssertionError("Invalid connected stocks: " + json.dumps(verdict)) + return verdict + + +def require_native_boot(log): + require_clean_log(log) + text = log.read_text(encoding="utf-8", errors="replace") + for marker in ("Starting minecraft server version 1.15.2", + "Forge mod loading, version 31.2.57, for MC 1.15.2 with MCP 20200515.085601"): + if marker not in text: + raise AssertionError("Missing genuine native115 boot proof: " + marker) + + +def verify_source(args): + source = args.source.resolve() + definition = read_json(source / "connected-fixture.json") + if definition.get("format") != 1: + raise ValueError("Unsupported connected fixture format") + if definition.get("sourceProfile") != (getattr(args, "source_fixture_key", None) or args.source_key): + raise ValueError("Source fixture profile does not match the explicit source key") + before = fingerprint(source) + expected = read_json(args.source_hashes) + if not expected or fingerprint(source / WORLD) != expected: + raise AssertionError("Protected connected source world fingerprint mismatch") + profile = pinned_profile(args.source_manifest.resolve(), args.source_key) + pins = sorted((jar.name, digest(jar)) for jar in profile[2]) + staged = read_json(source / "input-jars.json") + if sorted((entry["file"], entry["sha256"].upper()) for entry in staged) != pins: + raise AssertionError("Source fixture inputs differ from the explicit archived profile") + if sorted((jar.name, digest(jar)) for jar in (source / "mods").glob("*.jar")) != pins: + raise AssertionError("Source runtime jars differ from the explicit archived profile") + if not re.fullmatch(r"[A-Za-z0-9_-]+", args.source_checkpoint): + raise ValueError("Unsafe source checkpoint") + snapshot = connected.read(source, args.source_checkpoint) + if snapshot["snapshot"] != read_json(source / f"migration-{args.source_checkpoint}.json"): + raise AssertionError("Retained connected and migration checkpoints disagree") + verdict = require_document(snapshot, definition, destination=False) + if snapshot.get("requestedTicks", 0) <= 0 or snapshot.get("advancedTicks") != snapshot["requestedTicks"]: + raise AssertionError("Source checkpoint lacks an exact measured native interval") + if args.source_checkpoint == "source": + source_operation = connected.operation(connected.read(source, "cold"), snapshot, snapshot["requestedTicks"]) + if source_operation["status"] != "PASS": + raise AssertionError("Retained source did not operate: " + json.dumps(source_operation)) + history = source.parent / "connected-results.json" + historical = read_json(history) if history.is_file() else None + if historical is not None and historical.get("sources", {}).get(args.source_key, {}).get("status") != "PASS": + raise AssertionError("Retained source operation did not pass; do not manufacture a replacement") + if fingerprint(source) != before: + raise AssertionError("Source changed during preflight") + return definition, snapshot, verdict, before, historical + + +def prepare_target(runtime, profile, definition, port, source, pins): + if profile[1] != LAUNCHER: + raise ValueError("Only the genuine native115 launcher is permitted") + launcher = connected.prepare(runtime, profile, definition, port, source) + # Existing chunks are copied unchanged. Only the new server's launch settings + # replace the legacy semicolon flat-generator syntax with native defaults. + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() if not line.startswith("generator-settings=")] + properties.write_text("\n".join(lines) + "\ngenerator-settings=\n", encoding="utf-8") + for marker in ("connected-build.allow", "connected-built.json"): + if (runtime / marker).exists(): + raise AssertionError("Source construction authority leaked into the destination") + expected = sorted((Path(jar["path"]).name, jar["sha256"].upper()) for jar in pins["jars"]) + actual = sorted((jar.name, digest(jar)) for jar in (runtime / "mods").glob("*.jar")) + if actual != expected: + raise AssertionError("Staged native candidate jars differ from the pinned profile") + if digest(runtime / launcher) != pins["launcher_sha256"].upper(): + raise AssertionError("Staged native launcher differs from the pinned profile") + return launcher + + +class NativeConnectedServer(connected.ConnectedServer): + def __init__(self, runtime, java, launcher, label, on_loaded): + if launcher != LAUNCHER: + raise AssertionError("Refusing a non-native115 process") + self.on_loaded = on_loaded + self.loaded_verified = False + super().__init__(runtime, java, launcher, label) + try: + require_native_boot(Path(self.log.name)) + except BaseException: + self.close() + raise + + def send(self, command): + require_command(command) + if command.startswith("advconnected step ") and not self.loaded_verified: + raise AssertionError("Preservation must pass before any destination operation") + super().send(command) + + def observe(self, operation_name, label, ticks=0): + super().observe(operation_name, label, ticks) + if operation_name == "snapshot" and ticks == 0: + self.on_loaded(label) + self.loaded_verified = True + + +def continued_output(first, last): + initial, final = connected.state(first), connected.state(last) + missing = [key for key, case in initial["cases"].items() + if key != "battery" and case["remaining"] > 0 + and final["cases"][key]["produced"] <= case["produced"]] + return dict(status="FAIL" if missing else "PASS", failures=missing) + + +def run(args): + if not 1 <= args.port <= 65535 or not 1 <= args.ticks <= 4000: + raise ValueError("Port/tick interval outside native command bounds") + output = output_path(args.output) + for protected in (args.source, args.source_manifest, args.source_hashes, args.target_manifest): + require_disjoint(output, protected) + definition, checkpoint, source_state, source_before, historical = verify_source(args) + target = pinned_profile(args.target_manifest.resolve(), "target115") + target_pins = read_json(args.target_manifest)["target115"] + identities = {entry["id"] for entry in target_pins["jars"]} + if not {"steamadvantage", "electricadvantage"}.issubset(identities): + raise ValueError("Connected qualification requires native Steam and Electric candidates") + require_disjoint(output, target[0]) + for jar in target[2]: + require_disjoint(output, jar) + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + output.mkdir(parents=True, exist_ok=False) + runtime = output / "target115" + report_path = output / "native115-connected-results.json" + report = dict(status="RUNNING", scope="prebuilt-connected-source-to-native115-two-boots", + protocol="No source process; destination snapshot/step/save/stop only; no rebuilding/refilling/resetting", + source=str(args.source.resolve()), sourceKey=args.source_key, + sourceFixtureKey=getattr(args, "source_fixture_key", None) or args.source_key, + sourceCheckpoint=args.source_checkpoint, sourceState=source_state, + sourceManifest=str(args.source_manifest.resolve()), sourceManifestSha256=digest(args.source_manifest), + sourceHashes=str(args.source_hashes.resolve()), sourceHashesSha256=digest(args.source_hashes), + sourceRuntimeBefore=source_before, sourceWorldExpected=read_json(args.source_hashes), + sourceCheckpointSha256=digest(args.source / f"connected-{args.source_checkpoint}.json"), + targetManifest=str(args.target_manifest.resolve()), targetManifestSha256=digest(args.target_manifest), + targetProfile=target_pins, port=args.port, ticks=args.ticks, + runnerSha256=digest(Path(__file__)), oracleSha256=digest(Path(connected.__file__)), + historicalCampaign=historical, results={}) + write_json(report_path, report) + try: + launcher = prepare_target(runtime, target, definition, args.port, args.source, target_pins) + report["stagedInputs"] = read_json(runtime / "input-jars.json") + for label, previous in (("converted", args.source / f"migration-{args.source_checkpoint}.json"), + ("restarted", runtime / "migration-converted-worked.json")): + def on_loaded(loaded_label): + loaded = connected.read(runtime, loaded_label) + require_document(loaded, definition, destination=True) + if loaded.get("requestedTicks") != 0 or loaded.get("advancedTicks") != 0: + raise AssertionError("Initial preservation requires a frozen zero-step snapshot") + result = compare(previous, runtime / f"migration-{loaded_label}.json", native_normalized) + report["results"][label + "_preservation"] = result + write_json(report_path, report) + if result["status"] != "PASS": + raise AssertionError(label + " native115 preservation failed") + + def boot(current, settings, selected_launcher, selected_label): + if current.resolve() != runtime.resolve(): + raise AssertionError("Only the newly staged target may be launched") + return NativeConnectedServer(current, settings.java8, selected_launcher, selected_label, on_loaded) + + # Dependency injection is confined to the process factory, never an oracle. + with patch.object(connected, "boot", boot): + verdict = connected.resume(runtime, args, launcher, label) + report["results"][label + "_operation"] = verdict + require_document(connected.read(runtime, label + "-worked"), definition, destination=True) + require_native_boot(runtime / f"console-{label}.log") + report["results"][label + "_native_boot"] = dict(status="PASS", launcher=launcher) + write_json(report_path, report) + report["results"]["real_output_across_restarts"] = continued_output( + connected.read(runtime, "converted-loaded"), connected.read(runtime, "restarted-worked")) + report["status"] = "PASS" if all(result["status"] == "PASS" for result in report["results"].values()) else "FAIL" + except BaseException as error: + report.update(status="FAIL", failure=str(error)) + raise + finally: + source_after = fingerprint(args.source) + report["sourceRuntimeAfter"] = source_after + preserved = source_after == source_before + report["sourcePreservation"] = dict(status="PASS" if preserved else "FAIL") + if not preserved: + report.update(status="FAIL", failure="Protected source runtime changed") + write_json(report_path, report) + if not preserved: + raise AssertionError("Protected source runtime changed") + print(report["status"] + ": " + str(report_path), flush=True) + return int(report["status"] != "PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--source-manifest", type=Path, required=True) + parser.add_argument("--source-key", required=True) + parser.add_argument("--source-fixture-key", help="Explicit retained assembly lineage when the source manifest is target113/target114") + parser.add_argument("--source-hashes", type=Path, required=True) + parser.add_argument("--source-checkpoint", default="source") + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=25576) + parser.add_argument("--ticks", type=int, default=512) + return run(parser.parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_endurance.py b/test-rig/migration/native115_endurance.py new file mode 100644 index 0000000..6f8a7dc --- /dev/null +++ b/test-rig/migration/native115_endurance.py @@ -0,0 +1,330 @@ +"""Measured native115 endurance using unchanged batch and conservation oracles.""" + +import argparse +from collections import Counter +from datetime import datetime, timezone +import json +from pathlib import Path +import shutil +import socket +import time +import traceback + +import nbtlib + +import endurance +from audit_endurance import expected_inventory_acknowledgements +from matrix import WORLD, compare, digest, fingerprint, write_json +from native115 import native_normalized, output_path, pinned_profile, require_disjoint +from native115_lifecycle import (prepare, read_json, require_native_boot, + verify_source, verify_staged) + + +NAMES = ("fresh115", "original110-chained113-native115") +FRESH_FIXTURE_SEED = 5919679243107101036 +POI_POSITION = (-126, 5, 27) + + +def world_info(path): + data = nbtlib.load(path)["Data"] + return dict(MapFeatures=int(data["MapFeatures"]), RandomSeed=int(data["RandomSeed"]), + generatorName=str(data["generatorName"])) + + +def configure_new_fresh_fixture(runtime): + endurance.require(not (runtime / WORLD).exists(), "Fresh isolation refuses an existing/imported world") + properties = runtime / "server.properties" + before = digest(properties) + lines = [line for line in properties.read_text(encoding="utf-8").splitlines() + if line.strip().partition("=")[0] not in ("generate-structures", "level-seed")] + properties.write_text("\n".join(lines) + "\ngenerate-structures=false\n" + + f"level-seed={FRESH_FIXTURE_SEED}\n", encoding="utf-8") + proof = runtime / "fresh-fixture-proof" + proof.mkdir(exist_ok=False) + shutil.copy2(properties, proof / "server-before-first-creation.properties") + return dict(existing_world_absent=True, original_properties_sha256=before, + configured_properties_sha256=digest(properties)) + + +def verify_fresh_fixture_proof(runtime, proof): + endurance.require(proof.get("existing_world_absent") is True, "Missing new-world isolation guard") + for name, expected in proof["files"].items(): + endurance.require(digest(runtime / name) == expected, "Fresh isolation evidence changed: " + name) + properties = dict(line.split("=", 1) for line in + (runtime / "fresh-fixture-proof/server-before-first-creation.properties") + .read_text(encoding="utf-8").splitlines() if line and not line.startswith("#") and "=" in line) + endurance.require(properties.get("generate-structures") == "false" + and properties.get("level-seed") == str(FRESH_FIXTURE_SEED), "Wrong fresh creation settings") + actual = world_info(runtime / "fresh-fixture-proof/worldinfo-before-build.dat") + endurance.require(actual["MapFeatures"] == 0, "Actual WorldInfo MapFeatures is not zero") + endurance.require(actual["RandomSeed"] == FRESH_FIXTURE_SEED, "Actual WorldInfo seed changed") + endurance.require(actual == proof["actual_world_info"], "WorldInfo report differs from raw NBT") + rows = read_json(runtime / "migration-fresh-isolation-before-build.json") + endurance.require(len(rows) == 1 and tuple(rows[0][key] for key in ("x", "y", "z")) == POI_POSITION, + "Missing exact original composter coordinate") + endurance.require(rows[0]["actualName"] == "minecraft:air", "Original composter coordinate is not AIR") + endurance.require(rows[0] == proof["critical_block"], "Critical block report differs from native snapshot") + return actual + + +def prove_fresh_fixture_before_build(rig, configuration): + runtime = rig.runtime + write_json(runtime / "migration-fixture.json", [dict(id="poi-target", x=POI_POSITION[0], + y=POI_POSITION[1], z=POI_POSITION[2])]) + rig.server.snapshot("fresh-isolation-before-build") + rig.command("save-all flush", "Saved the game") + saved = runtime / "fresh-fixture-proof/worldinfo-before-build.dat" + shutil.copy2(runtime / WORLD / "level.dat", saved) + files = ["fresh-fixture-proof/server-before-first-creation.properties", + "fresh-fixture-proof/worldinfo-before-build.dat", "migration-fresh-isolation-before-build.json"] + proof = dict(configuration, status="UNVERIFIED", actual_world_info=world_info(saved), + critical_block=read_json(runtime / files[-1])[0], files={p: digest(runtime / p) for p in files}, + scope="NEW fresh world only, before any Works build; no imported world or POI data edits") + path = runtime / "fresh-fixture-proof.json" + write_json(path, proof) + verify_fresh_fixture_proof(runtime, proof) + proof["status"] = "PASS" + write_json(path, proof) + return proof + + +class NativeEnduranceRig(endurance.Rig): + def boot(self, phase): + super().boot(phase) + if phase != "endurance-restart": + return + # Restore only chunk tickets, never station actions or machine NBT. + # Observing a real tick is transport readiness, not a desired output. + for station in endurance.CHECKED: + definition = self.definitions[station] + low = self.pos(station, definition["boundsMin"]) + high = self.pos(station, definition["boundsMax"]) + self.command(f"forceload add {low[0]} {low[2]} {high[0]} {high[2]}") + self.checkpoint("native-restart-immediate") + initial = self.documents["W-01"]["worldTime"] + started = time.monotonic() + for attempt in range(200): + self.checkpoint(f"native-restart-ready-{attempt:03}") + observed = self.documents["W-01"]["worldTime"] + if observed > initial: + write_json(self.runtime / "native-restart-readiness.json", { + "initial_world_time": initial, "selected_world_time": observed, + "selected_attempt": attempt, "elapsed_seconds": time.monotonic() - started, + "selection": "First measured tick advance; no inventory/output inspection", + }) + return + if time.monotonic() - started >= 20: + break + time.sleep(0.01) + raise TimeoutError("No actual native tick after restoring endurance tickets") + + +def audit(root, require_endurance=True): + report = read_json(root / "native115-endurance-results.json") + expected_status = "PASS" if require_endurance else "SMOKE_PASS_NOT_ENDURANCE" + endurance.require(report["status"] == expected_status, "No completed native endurance result") + endurance.require(report["interval_seconds"] == 60, "Not the one-minute batch protocol") + endurance.require(report["duration_seconds"] >= (1800 if require_endurance else 60), "Duration too short") + endurance.require(report["source_fixture_unchanged"] and report["helpers_unchanged"], "Source/protocol changed") + endurance.require(set(report["profiles"]) == set(NAMES), "Missing native environment") + endurance.require(report.get("fresh_fixture_proof", {}).get("status") == "PASS", "Missing fresh isolation proof") + verify_fresh_fixture_proof(root / NAMES[0], report["fresh_fixture_proof"]) + endurance.require(report["copied_world_info_before_build"] == report["source_world_info"], + "Imported world generation settings changed") + result = dict(status="PASS", scope="endurance" if require_endurance else "smoke-only", profiles={}) + failures = ("Unknown slot", "Could not replace", "Unknown or incomplete command", "Incorrect argument", + "Unknown item", "There is no container") + for name, profile in report["profiles"].items(): + endurance.require(profile["status"] == "PASS" and profile["restart"] == "PASS" + and profile["resumed_processing"] == "PASS", "Incomplete profile") + endurance.require(profile["elapsed_seconds"] >= report["duration_seconds"], "Wall time too short") + endurance.require(profile["game_ticks"] >= report["duration_seconds"] * 18, "Tick rate too low") + runtime = root / name + if name != NAMES[0]: + endurance.require(world_info(runtime / WORLD / "level.dat") == report["source_world_info"], + "Saved imported world generation settings changed") + counts = expected_inventory_acknowledgements(profile["endurance_batches"]) + for filename, count in zip(("console-endurance.log", "console-endurance-restart.log"), counts): + text = (runtime / filename).read_text(encoding="utf-8") + require_native_boot(runtime / filename) + endurance.require(not any(f in text for f in failures), "Native command failure") + endurance.require(text.count("Replaced a slot at") == count, "Inventory command acknowledgements differ") + if "restart" in filename: + endurance.require(not any(f"Started {s} " in text for s in ("S-04", "E-03", "E-02", "S-02", "E-01")), + "Restart replaced source machines") + endurance.require("Advantage Works built" not in text and "Reset 1 station" not in text, + "Restart rebuilt the rig") + pins = {item["file"]: item["sha256"] for item in report["artifacts"]} + actual = {jar.name: digest(jar) for jar in (runtime / "mods").glob("*.jar")} + endurance.require(actual == pins, "Staged native artifacts changed") + ledger = read_json(runtime / "endurance-ledger.json") + batches = [b for b in ledger if b["label"].startswith("cycle-")] + total = report["duration_seconds"] // report["interval_seconds"] + endurance.require(len(batches) == profile["endurance_batches"] == total, "Batch count differs") + endurance.require({b["label"] for b in batches} == {f"cycle-{i:03}" for i in range(1, total + 1)}, + "Repeated or missing batch") + endurance.require([b["label"] for b in ledger[-3:]] == ["before-restart", "after-restart", "restart-resumed"], + "Missing restart checkpoints") + result["profiles"][name] = dict(status="PASS", batches=total, inventory_acknowledgements=list(counts)) + return result + + +def run(args): + endurance.require(args.seconds >= 60 and args.seconds % 60 == 0, "Use whole one-minute batches") + output = output_path(args.output) + for protected in (args.source, args.source_manifest, args.source_hashes, args.target_manifest): + require_disjoint(output, protected) + source_before, history = verify_source(args) + target = pinned_profile(args.target_manifest.resolve(), "target115") + for protected in (target[0], *target[2]): + require_disjoint(output, protected) + for port in (args.port, args.port + 1): + with socket.socket() as listener: + listener.bind(("127.0.0.1", port)) + definition_path = Path(__file__).parents[1] / "src/main/resources/assets/advantageworkstest/works/works-1.13.2.json" + definitions = {s["id"]: s for s in read_json(definition_path)["stations"]} + endurance.require(sum(bool(definitions[s].get("exclusive")) for s in endurance.ACTIVE) == 1, + "Exclusive stations interfere") + helpers = [Path(endurance.__file__), Path(__file__), definition_path] + before_helpers = {str(p): digest(p) for p in helpers} + output.mkdir(parents=True, exist_ok=False) + for path in helpers: + destination = output / "protocol-sources" / path.name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + report_path = output / "native115-endurance-results.json" + report = dict(status="RUNNING", duration_seconds=args.seconds, interval_seconds=60, + feeding_protocol="IInventory replaceitem and fluid handlers; no inventory NBT writes", + source=str(args.source.resolve()), source_state_gates=history, + source_fingerprint=source_before, helper_sha256=before_helpers, + source_world_info=world_info(args.source / WORLD / "level.dat"), + fresh_fixture_policy=dict(seed=FRESH_FIXTURE_SEED, generate_structures=False, + scope="NEW fresh115 world only; imported world settings preserved"), + target_manifest=str(args.target_manifest.resolve()), target_manifest_sha256=digest(args.target_manifest), + artifacts=[dict(file=j.name, sha256=digest(j)) for j in target[2]], + active_stations=endurance.ACTIVE, checked_stations=endurance.CHECKED, profiles={}, + coverage="Rebuilt Works in fresh and migrated-copy worlds; not endurance of original source machinery", + manual_checks="Machine GUIs, weapons, sound and player interaction remain manual") + write_json(report_path, report) + rigs = [] + try: + for index, name in enumerate(NAMES): + runtime = output / name + launcher = prepare(runtime, target, [], args.port + index, args.source if index else None) + configuration = configure_new_fresh_fixture(runtime) if index == 0 else None + for marker in ("migration-operate.allow", "advantage-works.allow"): + (runtime / marker).touch() + rig = NativeEnduranceRig(name, runtime, args.java8, launcher, definitions) + rigs.append(rig) + if index: + rig.boot("frozen-import") + rig.server.snapshot("frozen-import") + rig.command("save-all flush") + rig.shutdown() + preserved = compare(args.source / "migration-restarted.json", runtime / "migration-frozen-import.json", + native_normalized) + write_json(runtime / "native115-import-comparison.json", preserved) + endurance.require(preserved["status"] == "PASS", "Copied source did not preserve frozen state") + require_native_boot(runtime / "console-frozen-import.log") + rig.boot("endurance") + if index == 0: + report["fresh_fixture_proof"] = prove_fresh_fixture_before_build(rig, configuration) + else: + report["copied_world_info_before_build"] = world_info(runtime / WORLD / "level.dat") + endurance.require(report["copied_world_info_before_build"] == report["source_world_info"], + "Imported world generation settings changed") + write_json(report_path, report) + rig.start(build=True) + initial_ticks = {r.name: r.documents["W-01"]["worldTime"] for r in rigs} + started = time.monotonic() + report["started_at_utc"] = datetime.now(timezone.utc).isoformat() + write_json(report_path, report) + print(f"Measured {args.seconds}-second protocol started: " + report["started_at_utc"], flush=True) + cycles = args.seconds // 60 + for cycle in range(1, cycles + 1): + deadline = started + cycle * 60 + while time.monotonic() < deadline: + 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: + rig.checkpoint(f"cycle-{cycle:03}") + rig.batch(f"cycle-{cycle:03}") + 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(report_path, report) + print(f"{cycle * 60}s: both native profiles passed batch {cycle}", flush=True) + elapsed = time.monotonic() - started + ending_ticks = {r.name: r.documents["W-01"]["worldTime"] for r in rigs} + for rig in rigs: + endurance.require(ending_ticks[rig.name] - initial_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") + rig.shutdown() + rig.boot("endurance-restart") + rig.checkpoint("after-restart") + rig.batch("after-restart") + rig.structure() + rig.seed() + time.sleep(60) + for rig in rigs: + rig.checkpoint("restart-resumed") + rig.batch("restart-resumed") + statuses = rig.structure() + rig.command("save-all flush") + rig.shutdown() + 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, total_verified_checkpoints=len(rig.ledger), + station_statuses=statuses, restart="PASS", resumed_processing="PASS", + game_ticks=ending_ticks[rig.name] - initial_ticks[rig.name], + endurance_outputs=dict(totals)) + report["status"] = "PASS" if args.seconds >= 1800 else "SMOKE_PASS_NOT_ENDURANCE" + except BaseException as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + raise + finally: + for rig in rigs: + try: + rig.shutdown() + verify_staged(rig.runtime, target) + for log in rig.runtime.glob("console-*.log"): + require_native_boot(log) + except Exception as error: + report.update(status="FAIL", shutdown_or_audit_error=str(error)) + report["source_fixture_unchanged"] = fingerprint(args.source.resolve()) == source_before + report["helpers_unchanged"] = {str(p): digest(p) for p in helpers} == before_helpers + if not report["source_fixture_unchanged"] or not report["helpers_unchanged"]: + report.update(status="FAIL", provenance_error="Protected source or protocol changed") + report["completed_at_utc"] = datetime.now(timezone.utc).isoformat() + write_json(report_path, report) + endurance.require(report["status"] != "FAIL", "See saved native endurance failure") + write_json(output / "qualification-audit.json", audit(output, require_endurance=args.seconds >= 1800)) + print(report["status"] + ": " + str(report_path), flush=True) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--source-manifest", type=Path, required=True) + parser.add_argument("--source-key", default="target113") + parser.add_argument("--source-hashes", type=Path, required=True) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--seconds", type=int, default=1800) + parser.add_argument("--port", type=int, default=25590) + return run(parser.parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_extended.py b/test-rig/migration/native115_extended.py new file mode 100644 index 0000000..a421bf4 --- /dev/null +++ b/test-rig/migration/native115_extended.py @@ -0,0 +1,82 @@ +"""Four native moves, partial networks, blocked output, restart and redstone tests.""" + +import time + +import extended_lifecycle as historical +from native115_partial import NativeExtendedRun, campaign, common_parser, orders + + +def exercise(runner, progress): + definition = runner.definition + runner.boot("extended") + runner.build() + runner.probe("trace-loads", "trace-enabled") + result = dict(status="RUNNING") + low = runner.probe("low-prime", "low-before") + runner.release_drills(0) + time.sleep(2) + stalled = runner.probe("snapshot", "low-stalled") + result["insufficient_energy"] = historical.reserve_results(definition, low, stalled) + runner.hold_drills(0) + for step in range(4): + runner.probe("prime", f"prime-step-{step + 1}") + runner.release_drills(step) + moved = runner.wait_step(step + 1) + print(f"{runner.runtime.name}: native drill step {step + 1}/4 observed", flush=True) + result["moves"] = historical.movement_results(definition, moved) + progress(result) + time.sleep(50) + runner.processing("network-commissioned", 1000) + result["partial_networks"] = [] + for cycle, group in enumerate(("machine", "supply", "supply", "machine"), 1): + result["partial_networks"].append(runner.partial(group, cycle)) + runner.replenish() + runner.probe("offer", f"partial-fluid-{cycle}") + time.sleep(50) + runner.processing(f"partial-resumed-{cycle}", (cycle + 1) * 1000) + print(f"{runner.runtime.name}: {group}-only unload {cycle} and processing recorded", flush=True) + progress(result) + result["reload_orders"] = orders(runner, process=True) + runner.install_blocked_output() + time.sleep(45) + blocked = runner.probe("snapshot", "blocked-output") + result["blocked_output"] = historical.output_results(definition, blocked, True) + runner.command("save-all flush") + runner.close() + runner.boot("blocked-restart") + time.sleep(1) + retained = runner.probe("snapshot", "blocked-retained") + result["restart"] = historical.restart_results(definition, blocked, retained) + result["blocked_after_restart"] = historical.output_results(definition, retained, True) + runner.freed_output() + time.sleep(20) + result["resumed_output"] = historical.output_results(definition, runner.probe("snapshot", "freed-output"), False) + for f in definition: + if "route" in f: + runner.target_plane(f, "minecraft:obsidian") + time.sleep(4) + before = runner.probe("snapshot", "redstone-before") + runner.hold_drills(4) + time.sleep(2) + held = runner.probe("snapshot", "redstone-held") + runner.release_drills(4) + time.sleep(4) + after = runner.probe("snapshot", "redstone-resumed") + result["redstone"] = historical.redstone_results(definition, before, held, after) + runner.hold_drills(4) + runner.command("save-all flush") + runner.close() + result["network_processing"] = "PASS" + result["status"] = historical.qualification(result) + progress(result) + return result + + +def main(): + parser = common_parser(__doc__) + args = parser.parse_args() + return campaign(args, "extended", historical.fixtures(), exercise, NativeExtendedRun) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_extended_reads.py b/test-rig/migration/native115_extended_reads.py new file mode 100644 index 0000000..f817877 --- /dev/null +++ b/test-rig/migration/native115_extended_reads.py @@ -0,0 +1,47 @@ +"""All eighteen extended drill routes through mining, reload and saved-read guards.""" + +from pathlib import Path +import shutil +import time + +import extended_lifecycle as historical +from matrix import digest +from native115_partial import campaign, common_parser + + +def exercise(runner, progress): + protocol = Path(__file__) + original_hash = digest(protocol) + archive = runner.runtime / "native115-extended-reads-protocol.py" + shutil.copy2(protocol, archive) + runner.boot("extended-reads") + runner.build() + result = dict(status="RUNNING", protocolSha256=original_hash) + for step in range(4): + runner.probe("prime", f"prime-step-{step + 1}") + runner.release_drills(step) + moved = runner.wait_step(step + 1) + result["moves"] = historical.movement_results(runner.definition, moved) + runner.mine() + time.sleep(45) + result["mining"] = runner.mining_results(runner.probe("snapshot", "reads-mining-complete")) + progress(result) + print(runner.runtime.name + ": four moves and normal mining observed", flush=True) + result["mid_block_progress"] = runner.midblock() + progress(result) + result["saved_read_guards"] = runner.save_read_guards() + runner.command("save-all flush") + runner.close() + groups = [result["moves"], result["mining"], *result["mid_block_progress"].values(), + *result["saved_read_guards"].values()] + result["status"] = "PASS" if all(c["status"] == "PASS" for group in groups for c in group.values()) else "FAIL" + result["own_protocol_unchanged"] = digest(protocol) == original_hash == digest(archive) + if not result["own_protocol_unchanged"]: + result["status"] = "FAIL" + progress(result) + return result + + +if __name__ == "__main__": + args = common_parser(__doc__).parse_args() + raise SystemExit(campaign(args, "extended-reads", historical.fixtures(), exercise)) diff --git a/test-rig/migration/native115_geometry.py b/test-rig/migration/native115_geometry.py new file mode 100644 index 0000000..293b497 --- /dev/null +++ b/test-rig/migration/native115_geometry.py @@ -0,0 +1,21 @@ +"""Forge 31 geometry from exact installed sources, checked against live engine data.""" + +FORCED_LEVEL = 31 +FULL_LEVEL = 33 +MAX_DISTANCE = 11 +LAST_HOLDER_LEVEL = FULL_LEVEL + MAX_DISTANCE +HOLDER_RADIUS = LAST_HOLDER_LEVEL - FORCED_LEVEL +SOURCE_SHA256 = "937F0B7D7098D4D1A12EA306108122B1E443CA638D120D9040FF2B577E9A0314" + + +def require_geometry(snapshot, forced=False): + geometry = snapshot.get("ticketGeometry", {}) + expected = dict(fullLevel=FULL_LEVEL, entityTickingLevel=FORCED_LEVEL, + maxDistance=MAX_DISTANCE, lastHolderLevel=LAST_HOLDER_LEVEL, + holderRadius=HOLDER_RADIUS) + if any(geometry.get(key) != value for key, value in expected.items()): + raise AssertionError("Live native115 ticket geometry differs from the verified engine: " + str(geometry)) + levels = geometry.get("observedForcedLevels") + if levels not in ([], [FORCED_LEVEL]) or forced and levels != [FORCED_LEVEL]: + raise AssertionError("Actual native forced-ticket level was not verified: " + str(levels)) + return geometry diff --git a/test-rig/migration/native115_harvest.py b/test-rig/migration/native115_harvest.py new file mode 100644 index 0000000..f611f59 --- /dev/null +++ b/test-rig/migration/native115_harvest.py @@ -0,0 +1,152 @@ +"""Actual survival mining and silk-touch drops in two clean native115 boots.""" + +import argparse +from datetime import datetime, timezone +from pathlib import Path +import traceback + +from matrix import Server, digest, write_json +from native115 import output_path, pinned_profile, require_disjoint +from native115_lifecycle import prepare, read_json, require_native_boot, verify_staged + + +EXPECTED = { + "sulfur/hand-fails": (0, False, False, 0, 0, 1), + "sulfur/axe-fails": (0, False, False, 0, 0, 1), + "sulfur/wood": (0, True, False, 1, 4, 8), + "sulfur/stone": (0, True, False, 1, 4, 8), + "sulfur/silk": (0, True, True, 1, 1, 1), + "lithium/hand-fails": (1, False, False, 0, 0, 1), + "lithium/wood-fails": (1, False, False, 0, 0, 1), + "lithium/gold-fails": (1, False, False, 0, 0, 1), + "lithium/axe-fails": (1, False, False, 0, 0, 1), + "lithium/stone": (1, True, False, 1, 1, 1), + "lithium/diamond": (1, True, False, 1, 1, 1), + "lithium/silk": (1, True, True, 1, 1, 1), + "frame/hand": (-1, True, False, 1, 1, 1), + "frame/silk": (-1, True, True, 1, 1, 1), +} +for _mask in range(64): + for _family in ("wire", "scaffold"): + EXPECTED[f"{_family}/{_mask}/hand"] = (-1, True, False, 1, 1, 1) + EXPECTED[f"{_family}/{_mask}/silk"] = (-1, True, True, 1, 1, 1) + +ISOLATION_PROTOCOL = "native115-live-drops-v1" + + +def require_checks(document, label): + rows = document.get("checks", []) + actual = {row["id"]: row for row in rows} + if (document.get("label") != label or document.get("minecraft") != "1.15.2" + or actual.keys() != EXPECTED.keys() | {"solar/native-solidity"} + or len(rows) != len(EXPECTED) + 1): + raise AssertionError("Incomplete native115 harvest witness set") + if document.get("status") != "PASS": + failures = [row["id"] + ": " + row.get("failure", row.get("status", "missing status")) + for row in rows if row.get("status") != "PASS"] + raise AssertionError("Native115 harvest witnesses failed: " + "; ".join(failures[:5])) + for name, (level, allowed, silk, minimum, maximum, trials) in EXPECTED.items(): + row = actual[name] + counts = row.get("actualDropCounts", []) + mask = int(name.split("/")[1]) if name.startswith(("wire/", "scaffold/")) else -1 + if (row.get("status") != "PASS" or row.get("historicalHarvestLevel") != level + or row.get("harvestAllowed") is not allowed or row.get("silkTouch") is not silk + or row.get("connectionMask") != mask + or row.get("actualSolid") is not name.startswith(("sulfur/", "lithium/", "wire/")) + or row.get("fullCollision") is not (not name.startswith("wire/")) + or row.get("trials") != trials or len(counts) != trials + or any(type(count) is not int or not minimum <= count <= maximum for count in counts)): + raise AssertionError("Historical mining/drop oracle failed: " + name) + if actual["solar/native-solidity"].get("status") != "PASS" or actual["solar/native-solidity"].get("actualSolid") is not True: + raise AssertionError("Historical photovoltaic solid=true property was lost") + + +def require_isolation(document): + if document.get("fixtureIsolation") != ISOLATION_PROTOCOL: + raise AssertionError("Missing native115 live-drop fixture isolation protocol") + seen_ids, seen_uuids = set(), set() + for row in document["checks"]: + if row["id"] == "solar/native-solidity": + continue + evidence = row.get("dropEvidence", []) + if len(evidence) != row["trials"]: + raise AssertionError("Incomplete per-trial drop isolation evidence: " + row["id"]) + for trial, entry in enumerate(evidence): + if (entry.get("trial") != trial or type(entry.get("liveAfterCleanup")) is not int + or entry["liveAfterCleanup"] != 0 + or any(type(entry.get(key)) is not int or entry[key] < 0 + for key in ("liveBeforeCleanup", "removedBeforeCleanup", "worldTime"))): + raise AssertionError("Harvest trial was not isolated: " + row["id"]) + drops = entry.get("actualDrops") + if not isinstance(drops, list): + raise AssertionError("Missing actual native drop identities: " + row["id"]) + count = 0 + for drop in drops: + entity_id, uuid = drop.get("entityId"), drop.get("uuid") + if (type(entity_id) is not int or entity_id in seen_ids or not isinstance(uuid, str) + or not uuid or uuid in seen_uuids or drop.get("removed") is not False + or not isinstance(drop.get("item"), str) or not drop["item"] + or type(drop.get("count")) is not int or drop["count"] <= 0): + raise AssertionError("Removed, repeated or invalid native harvest drop: " + row["id"]) + seen_ids.add(entity_id) + seen_uuids.add(uuid) + count += drop["count"] + if count != row["actualDropCounts"][trial]: + raise AssertionError("Native drop identities disagree with counted items: " + row["id"]) + + +def mark_runtime(output): + (output / "advantage-native-harvest.allow").touch() + (output / "advantage-works.allow").touch() + + +def run(args): + output = output_path(args.output) + require_disjoint(output, args.target_manifest) + target = pinned_profile(args.target_manifest.resolve(), "target115") + for protected in (target[0], *target[2]): + require_disjoint(output, protected) + launcher = prepare(output, target, [], args.port) + mark_runtime(output) + report = dict(status="RUNNING", started_at_utc=datetime.now(timezone.utc).isoformat(), phases={}, + fixture_isolation_protocol=ISOLATION_PROTOCOL, runner_sha256=digest(Path(__file__)), + target_manifest=str(args.target_manifest.resolve()), target_manifest_sha256=digest(args.target_manifest), + scope="Real survival PlayerInteractionManager harvesting, failed tools, exact legacy drop bounds, silk touch and full restart", + source_baseline="EA 1.10/1.12 sulfur pickaxe 0 quantity 1..4; lithium pickaxe 1 quantity 1; Power steel-frame normal harvesting") + server = None + try: + for label in ("exercise", "restored"): + server = Server(output, args.java8, launcher, label) + server.send("forceload add 1198 1022 1202 1026") + server.send("advharvest " + label) + server.wait("ADVHARVEST COMPLETE " + label, 120) + document = read_json(output / "advantage-works-results" / f"native-harvest-{label}.json") + report["phases"][label] = document + server.send("save-all flush") + server.close() + server = None + require_native_boot(output / f"console-{label}.log") + require_checks(document, label) + require_isolation(document) + verify_staged(output, target) + report["status"] = "PASS" + except Exception as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + finally: + if server: + try: + server.close() + except Exception as error: + report.update(status="FAIL", shutdown_error=str(error)) + report["completed_at_utc"] = datetime.now(timezone.utc).isoformat() + write_json(output / "native115-harvest-results.json", report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=25596) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native115_holder_networks.py b/test-rig/migration/native115_holder_networks.py new file mode 100644 index 0000000..4c05e8c --- /dev/null +++ b/test-rig/migration/native115_holder_networks.py @@ -0,0 +1,83 @@ +"""Real holder-unload fixtures outside Forge's full generation dependency halo.""" + +from pathlib import Path +import shutil + +import extended_lifecycle as historical +from matrix import digest +import native115_extended +from native115_partial import campaign, common_parser, exercise_bridge, exercise_partial +from native115_ticket_networks import NativeTicketExtendedRun, NativeTicketNetworkRun + + +from native115_geometry import FORCED_LEVEL, LAST_HOLDER_LEVEL, HOLDER_RADIUS +ANCHOR_DISTANCE = 32 + + +def holder_halo(fixture, group): + return [[x, z] for x in range(fixture["min"][0] >> 4, (fixture["max"][0] >> 4) + 1) + for z in range(fixture["min"][2] >> 4, (fixture["max"][2] >> 4) + 1) + if any(max(abs(x-a), abs(z-b)) <= HOLDER_RADIUS for a, b in fixture["groups"][group])] + + +def linear_fixtures(): + result = [] + for index, name in enumerate(("C-STEAM", "C-ELECTRIC", "C-FLUID")): + base = 32768 + index * 1024 + origin, sink = [base + 8, 80, 2056], [base + 520, 80, 2056] + pipes = [[x, 80, 2056] for x in range(base + 9, base + 520)] + f = dict(id=name, nativeTicketNetwork=True, min=[base, 72, 2048], max=[base + 543, 90, 2079], + witnesses=[origin, *pipes[:3], sink, *pipes[3:]], pipes=pipes, + groups=dict(supply=[[base >> 4, 128]], machine=[[(base >> 4) + ANCHOR_DISTANCE, 128]])) + for group in ("supply", "machine"): + f["groups"][group + "halo"] = holder_halo(f, group) + result.append(f) + return result + + +def bridge_fixtures(): + result = [] + for index, name in enumerate(("C-STEAM", "C-ELECTRIC", "C-FLUID")): + base = 36864 + index * 1024 + origin, sink = [base + 13, 80, 2056], [base + 9, 80, 2056] + path = [[base + 13, 80, z] for z in range(2057, 2569)] + path += [[base + x, 80, 2568] for x in range(12, 8, -1)] + path += [[base + 9, 80, z] for z in range(2567, 2056, -1)] + f = dict(id=name, nativeTicketNetwork=True, min=[base, 72, 2048], max=[base + 31, 90, 2591], + witnesses=[origin, sink, *path], pipes=path, + groups=dict(bridge=[[base >> 4, 160]], endpoints=[[base >> 4, 128]])) + f["groups"]["bridgehalo"] = holder_halo(f, "bridge") + result.append(f) + return result + + +def extended_fixtures(): + return [f for f in historical.fixtures() if "route" in f] + linear_fixtures() + + +def main(): + parser = common_parser(__doc__) + parser.add_argument("--campaign", choices=("partial", "bridge", "extended"), required=True) + args = parser.parse_args() + definitions = {"partial": linear_fixtures, "bridge": bridge_fixtures, "extended": extended_fixtures} + operations = {"partial": exercise_partial, "bridge": exercise_bridge, "extended": native115_extended.exercise} + runner_type = NativeTicketNetworkRun if args.campaign == "bridge" else NativeTicketExtendedRun + own, own_hash = Path(__file__), digest(Path(__file__)) + transport = Path(__file__).with_name("native115_ticket_networks.py") + transport_hash = digest(transport) + def exercise(runner, progress): + shutil.copy2(own, runner.runtime / own.name) + shutil.copy2(transport, runner.runtime / transport.name) + result = operations[args.campaign](runner, progress) + result.update(holder_protocol_sha256=own_hash, holder_transport_sha256=transport_hash, + holder_protocol_unchanged=(digest(own) == own_hash == digest(runner.runtime / own.name) + and digest(transport) == transport_hash == digest(runner.runtime / transport.name))) + if not result["holder_protocol_unchanged"]: + result["status"] = "FAIL" + progress(result) + return result + return campaign(args, "holder-" + args.campaign, definitions[args.campaign](), exercise, runner_type) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_interop.py b/test-rig/migration/native115_interop.py new file mode 100644 index 0000000..de57882 --- /dev/null +++ b/test-rig/migration/native115_interop.py @@ -0,0 +1,72 @@ +"""Native capability, portable loot and locked automation witnesses on disposable servers.""" + +import argparse +from datetime import datetime, timezone +from pathlib import Path +import traceback + +from matrix import Server, digest, write_json +from native115 import output_path, pinned_profile, require_disjoint +from native115_lifecycle import prepare, read_json, require_native_boot, verify_staged + + +def require_checks(document, phase): + expected = 20 if phase == "exercise" else 2 + if document.get("phase") != phase or len(document.get("checks", [])) != expected: + raise AssertionError("Incomplete native interop witness set") + if len({row["id"] for row in document["checks"]}) != expected: + raise AssertionError("Duplicate native interop witnesses") + failed = [row for row in document["checks"] if row.get("status") != "PASS"] + if failed or document.get("status") != "PASS": + raise AssertionError("Native interop defects: " + str(failed)) + + +def run(args): + output = output_path(args.output) + require_disjoint(output, args.target_manifest) + target = pinned_profile(args.target_manifest.resolve(), "target115") + for protected in (target[0], *target[2]): + require_disjoint(output, protected) + launcher = prepare(output, target, [], args.port) + (output / "advantage-native-interop.allow").touch() + report = dict(status="RUNNING", started_at_utc=datetime.now(timezone.utc).isoformat(), phases={}, + target_manifest=str(args.target_manifest.resolve()), target_manifest_sha256=digest(args.target_manifest), + scope="Live Forge capability contracts, both conversion directions, native tank loot, locked chest automation and full restart", + limitation="No third-party mod-specific energy implementation or real client input is claimed") + server = None + try: + for phase in ("exercise", "restored"): + server = Server(output, args.java8, launcher, phase) + server.send("forceload add 1023 1023 1100 1030") + server.send("advinterop " + phase) + server.wait("ADVINTEROP COMPLETE " + phase, 120) + report["phases"][phase] = read_json(output / "advantage-works-results" / f"native-interop-{phase}.json") + server.send("save-all flush") + server.close() + server = None + require_native_boot(output / f"console-{phase}.log") + for phase, document in report["phases"].items(): + require_checks(document, phase) + verify_staged(output, target) + report["status"] = "PASS" + except Exception as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + finally: + if server: + try: + server.close() + except Exception as error: + report.update(status="FAIL", shutdown_error=str(error)) + report["completed_at_utc"] = datetime.now(timezone.utc).isoformat() + write_json(output / "native115-interop-results.json", report) + print(report["status"] + ": " + str(output / "native115-interop-results.json"), flush=True) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=25592) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native115_lifecycle.py b/test-rig/migration/native115_lifecycle.py new file mode 100644 index 0000000..14ddda5 --- /dev/null +++ b/test-rig/migration/native115_lifecycle.py @@ -0,0 +1,541 @@ +"""Run unchanged lifecycle fixtures/oracles in fresh and retained-copy native115 worlds. + +Requires Python 3.11+, migration/requirements.txt, an explicit archived source +manifest and a hash-pinned native target manifest. Never launches a source. +""" + +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +import socket +import shutil +import time +import traceback + +import lifecycle +from matrix import WORLD, compare, copy_fixture, digest, fingerprint, stage, write_json +from native115 import (LAUNCHER, native_normalized, output_path, pinned_profile, + require_clean_log, require_disjoint) +from native115_geometry import require_geometry + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def portable_hashes(values): + return {key.replace("\\", "/"): value.upper() for key, value in values.items()} + + +def require_equal_hashes(actual, expected, detail): + if not expected or portable_hashes(actual) != portable_hashes(expected): + raise AssertionError(detail) + + +def verify_source(args): + source = args.source.resolve() + before = fingerprint(source) + require_equal_hashes(fingerprint(source / WORLD), read_json(args.source_hashes), + "Protected retained13 source world fingerprint mismatch") + profile = pinned_profile(args.source_manifest.resolve(), args.source_key) + if not profile[1].startswith(("forge-1.13.2-25.0.223", "forge-1.14.4-28.2.26")): + raise ValueError("The retained source must use its archived native 1.13.2 or accepted 1.14.4 profile") + expected = sorted((jar.name, digest(jar)) for jar in profile[2]) + staged = sorted((entry["file"], entry["sha256"].upper()) + for entry in read_json(source / "input-jars.json")) + actual = sorted((jar.name, digest(jar)) for jar in (source / "mods").glob("*.jar")) + if staged != expected or actual != expected: + raise AssertionError("Retained source jars differ from the explicit archived profile") + history = read_json(source / "state-comparison.json") + gates = ("converted", "restarted", "idempotence") if "converted" in history else ("conversion", "restart", "idempotence") + for gate in gates: + if history.get(gate, {}).get("status") != "PASS": + raise AssertionError("Retained source state gate did not pass: " + gate) + for name in ("migration-fixture.json", "migration-restarted.json"): + if not (source / name).is_file(): + raise FileNotFoundError(source / name) + if fingerprint(source) != before: + raise AssertionError("Retained source changed during preflight") + return before, history + + +def require_native_boot(path): + require_clean_log(path) + text = path.read_text(encoding="utf-8", errors="replace") + for marker in ("Starting minecraft server version 1.15.2", + "Forge mod loading, version 31.2.57, for MC 1.15.2 with MCP 20200515.085601"): + if marker not in text: + raise AssertionError("Missing genuine native115 boot proof: " + marker) + # Forge 31.2.57 ForgeConfig.onFileChange logs this exact notice at FATAL + # without throwing. No other fatal message is exempted from this adapter gate. + reload_notice = "[ne.mi.co.ForgeConfig/CORE]: Forge config just got changed on the file system!" + if any("/FATAL]" in line and not line.endswith(reload_notice) for line in text.splitlines()): + raise AssertionError("Fatal native runtime error in " + str(path)) + + +def verify_staged(runtime, profile): + expected = sorted((jar.name, digest(jar)) for jar in profile[2]) + actual = sorted((jar.name, digest(jar)) for jar in (runtime / "mods").glob("*.jar")) + recorded = sorted((entry["file"], entry["sha256"].upper()) + for entry in read_json(runtime / "input-jars.json")) + if actual != expected or recorded != expected: + raise AssertionError("Staged native jar set changed: " + str(runtime)) + if digest(runtime / LAUNCHER) != digest(profile[0] / LAUNCHER): + raise AssertionError("Staged native launcher changed") + + +def prepare(runtime, profile, definition, port, source=None): + if profile[1] != LAUNCHER: + raise ValueError("Refusing a non-native115 lifecycle launcher") + launcher = stage(runtime, profile) + if source is not None: + copy_fixture(source, runtime) + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() + if not line.startswith(("server-port=", "generator-settings="))] + properties.write_text("\n".join(lines) + f"\nserver-port={port}\ngenerator-settings=\n", encoding="utf-8") + write_json(runtime / "lifecycle-fixture.json", definition) + verify_staged(runtime, profile) + return launcher + + +def authorize_lifecycle(runtime): + for marker in ("migration-operate.allow", "advantage-works.allow", "advantage-lifecycle.allow"): + (runtime / marker).touch() + + +def require_groups(definition, groups): + expected = {fixture["id"] for fixture in definition if "from" in fixture} + if len(expected) != 12: + raise AssertionError("Lifecycle requires the original twelve drill cases") + return len(groups) == 7 and all(set(group) == expected and all(row.get("status") == "PASS" for row in group.values()) + for group in groups) + + +READINESS_SECONDS = 20 +READINESS_TICKS = 160 +READINESS_SAMPLES = 200 +DEFERRED_TICKS = 1 + + +def required_chunks(definition): + return {(x, z) for fixture in definition + for x in range(fixture["min"][0] >> 4, (fixture["max"][0] >> 4) + 1) + for z in range(fixture["min"][2] >> 4, (fixture["max"][2] >> 4) + 1)} + + +def loaded_issues(snapshot, definition): + issues = [] + chunks = snapshot.get("chunks", []) + if len(chunks) != len(required_chunks(definition)) or {tuple(c["pos"]) for c in chunks} != required_chunks(definition): + issues.append("fixture chunk witness set differs") + if any(not chunk.get("loaded") for chunk in chunks): + issues.append("fixture chunks unloaded") + for fixture in definition: + rows = snapshot.get("cases", {}).get(fixture["id"], []) + if len(rows) != len(fixture["witnesses"]) or {tuple(row["pos"]) for row in rows} != {tuple(p) for p in fixture["witnesses"]}: + issues.append(fixture["id"] + ": witness set differs") + if any(not row.get("loaded") for row in rows): + issues.append(fixture["id"] + ": witnesses unloaded") + return issues + + +def load_event_issues(before, after): + old = {tuple(chunk["pos"]): chunk for chunk in before["chunks"]} + events = {tuple(event["pos"]) for event in after.get("chunkEvents", []) if event["operation"] == "load"} + issues = [] + for chunk in after["chunks"]: + pos = tuple(chunk["pos"]) + previous = old.get(pos) + if (previous is None or pos not in events or chunk["loads"] <= 0 + or (not previous["loaded"] and chunk["loads"] <= previous["loads"])): + issues.append("missing actual Chunk.Load: " + str(pos)) + return issues + + +def unload_event_issues(before, after): + old = {tuple(chunk["pos"]): chunk for chunk in before["chunks"]} + current = {tuple(chunk["pos"]): chunk for chunk in after["chunks"]} + if not old or old.keys() != current.keys() or len(current) != len(after["chunks"]): + return ["unload chunk witness set differs"] + events = {tuple(event["pos"]) for event in after.get("chunkEvents", []) if event["operation"] == "unload"} + return ["missing actual Chunk.Unload: " + str(pos) for pos, chunk in current.items() + if pos not in events or chunk["unloads"] <= old[pos]["unloads"]] + + +class NativeLifecycleRun(lifecycle.LifecycleRun): + """Native transport readiness only; inherited fixtures, operations and oracles.""" + + def __init__(self, *args): + super().__init__(*args) + self.observations = [] + self._unload_context = None + + def _raw_probe(self, operation, label): + path = self.runtime / "advantage-works-results" / f"lifecycle-{label}.json" + if path.exists(): + raise FileExistsError("Refusing to overwrite native observation: " + str(path)) + result = super().probe(operation, label) + require_geometry(result, forced=operation == "load") + return result + + def _record(self): + write_json(self.runtime / "native115-readiness.json", self.observations) + + def _wait_ready(self, label, initial, reload_before=None): + started = time.monotonic() + record = dict(label=label, status="PENDING", min_real_ticks=DEFERRED_TICKS, + max_seconds=READINESS_SECONDS, max_ticks=READINESS_TICKS, + initial_world_time=initial["worldTime"], samples=[]) + self.observations.append(record) + loaded_at = None + snapshot = initial + try: + for attempt in range(READINESS_SAMPLES): + issues = loaded_issues(snapshot, self.definition) + issues += load_event_issues(reload_before if reload_before is not None else initial, snapshot) + now = snapshot["worldTime"] + if now < initial["worldTime"]: + raise AssertionError("Native worldTime moved backwards") + loaded_at = None if issues else (now if loaded_at is None else loaded_at) + sample = dict(label=label + ("-initial" if attempt == 0 else f"-pending-{attempt:03}"), + world_time=now, elapsed_seconds=time.monotonic() - started, issues=issues, + loaded_since_world_time=loaded_at) + record["samples"].append(sample) + if sample["elapsed_seconds"] >= READINESS_SECONDS or now - initial["worldTime"] >= READINESS_TICKS: + break + ready = loaded_at is not None and now - loaded_at >= DEFERRED_TICKS + if ready: + record.update(status="PASS", first_ready=sample, selected_label=sample["label"]) + self._record() + return snapshot + self._record() + if attempt + 1 == READINESS_SAMPLES: + break + # Yield between observations; elapsed sleep is never evidence of a native tick. + time.sleep(0.01) + snapshot = self._raw_probe("snapshot", label + f"-pending-{attempt + 1:03}") + raise TimeoutError("Native readiness did not observe loaded witnesses and actual ticks: " + label) + except Exception as error: + record.update(status="FAIL", failure=str(error)) + self._record() + raise + + def boot(self, label): + super().boot(label) + if label not in ("lifecycle-restart", "midblock-restart"): + return + require_native_boot(self.runtime / f"console-{label}.log") + before = self._raw_probe("snapshot", label + "-native-unrestored") + loaded = self._raw_probe("load", label + "-native-initial") + self._wait_ready(label + "-native", loaded, reload_before=before) + self.observations[-1]["reload_before_label"] = label + "-native-unrestored" + self._record() + + def unload_reload(self, name): + self._unload_context = dict(name=name, before=None, deadline=None) + try: + return super().unload_reload(name) + finally: + self._unload_context = None + + def _unload_observation(self, label): + context = self._unload_context + selected_label = label + "-native-initial" + snapshot = self._raw_probe("snapshot", selected_label) + record = dict(label=label, phase="native-unload-callback-readiness", samples=[]) + self.observations.append(record) + # Verified Forge 1.15 behavior queues Unload after holder removal. Keep the + # inherited 45-second budget; never fabricate flags or callback counters. + for attempt in range(45): + pending = unload_event_issues(context["before"], snapshot) + absent = all(not chunk["loaded"] for chunk in snapshot["chunks"]) + record["samples"].append(dict(label=selected_label, world_time=snapshot["worldTime"], + all_absent=absent, pending_events=pending)) + if not absent or not pending: + break + remaining = context["deadline"] - time.monotonic() + if remaining <= 0 or attempt == 44: + break + self._record() + time.sleep(min(1, remaining)) + selected_label = label + f"-native-pending-{attempt + 1:02}" + snapshot = self._raw_probe("snapshot", selected_label) + source = self.runtime / "advantage-works-results" / f"lifecycle-{selected_label}.json" + canonical = self.runtime / "advantage-works-results" / f"lifecycle-{label}.json" + if canonical.exists(): + raise FileExistsError(canonical) + shutil.copy2(source, canonical) + record.update(selected_label=selected_label, canonical_label=label, + status="READY" if absent and not pending else "PENDING") + self._record() + return snapshot + + def probe(self, operation, label): + context = self._unload_context + if context is not None: + prefix = context["name"] + "-absent-" + if operation == "snapshot" and label.startswith(prefix) and label[len(prefix):].isdigit(): + return self._unload_observation(label) + if operation == "snapshot" and label == context["name"] + "-before": + result = self._raw_probe(operation, label) + context["before"] = result + return result + if operation == "release" and label == context["name"] + "-released": + result = self._raw_probe(operation, label) + context["deadline"] = time.monotonic() + 45 + return result + if operation == "snapshot" and label == context["name"] + "-absent-confirmed": + result = self._raw_probe(operation, label) + context["absent"] = result + return result + if operation == "load" and label == context["name"] + "-loaded": + initial = self._raw_probe(operation, label + "-native-initial") + selected = self._wait_ready(label + "-native", initial, reload_before=context["absent"]) + source = self.runtime / "advantage-works-results" / ( + "lifecycle-" + self.observations[-1]["selected_label"] + ".json") + canonical = self.runtime / "advantage-works-results" / f"lifecycle-{label}.json" + if canonical.exists(): + raise FileExistsError(canonical) + shutil.copy2(source, canonical) + return selected + if operation not in ("legacy-read", "changed-target-read"): + return self._raw_probe(operation, label) + before = self._raw_probe("snapshot", label + "-native-before") + issues = loaded_issues(before, self.definition) + load_event_issues(before, before) + if issues: + raise AssertionError("Saved-read prerequisite is not loaded: " + "; ".join(issues)) + immediate = self._raw_probe(operation, label + "-native-initial") + selected = self._wait_ready(label + "-native", immediate) + record = self.observations[-1] + # Select by loaded states and real ticks, never by the expected oracle verdict. + selected_path = self.runtime / "advantage-works-results" / ("lifecycle-" + record["selected_label"] + ".json") + canonical = self.runtime / "advantage-works-results" / ("lifecycle-" + label + ".json") + if canonical.exists(): + raise FileExistsError(canonical) + shutil.copy2(selected_path, canonical) + record.update(operation=operation, canonical_label=label) + self._record() + return selected + + +def exercise(runner, label, progress, trace_loads=False): + """Original sequence/fixtures/oracles, with native observation readiness gates.""" + definition = runner.definition + runner.boot("lifecycle") + require_native_boot(runner.runtime / "console-lifecycle.log") + if trace_loads: + runner.probe("trace-loads", "tracing-loads") + runner.build() + moved = runner.wait_moves() + movements = {movement["id"]: movement for movement in moved["movements"]} + if len(movements) != len(moved["movements"]): + raise AssertionError("Duplicate movement witnesses") + movement_results = {fixture["id"]: lifecycle.move_result(fixture, movements[fixture["id"]]) + for fixture in definition if "from" in fixture} + result = dict(status="RUNNING", move_cases=movement_results, chunk_cycles=0) + progress(result) + print(f"{label}: twelve finite-energy moves observed", flush=True) + time.sleep(60) + runner.processing("commissioned", 1000) + for cycle in range(1, 4): + runner.unload_reload(f"chunk-cycle-{cycle}") + runner.replenish() + runner.probe("offer", f"fluid-offer-{cycle}") + time.sleep(60) + runner.processing(f"chunk-resumed-{cycle}", (cycle + 1) * 1000) + result["chunk_cycles"] = cycle + progress(result) + print(f"{label}: real chunk unload/reload {cycle} and resumed processing PASS", flush=True) + before = runner.probe("snapshot", "before-restart") + runner.command("save-all flush") + runner.close() + runner.boot("lifecycle-restart") + require_native_boot(runner.runtime / "console-lifecycle-restart.log") + time.sleep(1) + after = runner.probe("snapshot", "after-restart") + for fixture in definition: + if "to" not in fixture: + continue + old = lifecycle.at(before, fixture["id"], fixture["to"]) + new = lifecycle.at(after, fixture["id"], fixture["to"]) + if not (lifecycle.contents(old) == lifecycle.contents(new) and old["block"] == new["block"] + and "facing=" + fixture["facing"] in new["state"]): + raise AssertionError("Moved drill changed across full restart: " + fixture["id"]) + runner.processing("restart-retained", 4000) + runner.replenish() + runner.probe("offer", "restart-fluid-offer") + runner.mine() + time.sleep(60) + mined = runner.processing("restart-resumed", 5000) + mining_results = runner.mining_results(mined) + runner.unload_reload("mining-chunk-reload") + time.sleep(5) + mining_after = runner.mining_results(runner.probe("snapshot", "mining-retained")) + result.update(mining=mining_results, mining_after_chunk_reload=mining_after) + progress(result) + midblock_results = runner.midblock() + result["mid_block_progress"] = midblock_results + progress(result) + save_read_results = runner.save_read_guards() + failed_changed = [name for name, row in save_read_results["changed_target_read"].items() if row["status"] != "PASS"] + if failed_changed: + result["changed_target_observation_gap"] = dict(cases=failed_changed, detail= + "Strict oracle failed on the first loaded snapshot after an actual tick. Immediate/pending phases retained for retarget-race diagnosis; no verdict-based resampling.") + runner.command("save-all flush") + runner.close() + groups = (movement_results, mining_results, mining_after, midblock_results["chunk_reload"], + midblock_results["full_restart"], save_read_results["legacy_read"], + save_read_results["changed_target_read"]) + result.update(status="PASS" if require_groups(definition, groups) else "FAIL", chunk_cycles=5, + full_restart="PASS", network_processing="PASS", fluid_mB=5000, + mining=mining_results, mining_after_chunk_reload=mining_after, + mid_block_progress=midblock_results, save_read_guards=save_read_results) + progress(result) + print(f"{label}: {result['status']}; movement, mining, chunk and restart oracles retained", flush=True) + return result + + +def frozen_import(runner, source): + # This is a separate frozen boot, before construction authority or live ticks. + runner.boot("retained13-import") + try: + require_native_boot(runner.runtime / "console-retained13-import.log") + runner.server.snapshot("retained13-import") + runner.command("save-all flush") + time.sleep(2) + finally: + runner.close() + result = compare(source / "migration-restarted.json", runner.runtime / "migration-retained13-import.json", + native_normalized) + write_json(runner.runtime / "native115-import-comparison.json", result) + if result["status"] != "PASS": + raise AssertionError("Frozen retained13-to-native115 preservation failed") + return result + + +def run(args): + if not 1 <= args.port <= 65535 or not args.java8.is_file(): + raise ValueError("Supply an available TCP port and an existing Java 8 executable") + output = output_path(args.output) + for protected in (args.source, args.source_manifest, args.source_hashes, args.target_manifest): + require_disjoint(output, protected) + source_before, history = verify_source(args) + target = pinned_profile(args.target_manifest.resolve(), "target115") + identities = {entry["id"] for entry in read_json(args.target_manifest)["target115"]["jars"]} + if not {"steamadvantage", "electricadvantage", "basemetals"}.issubset(identities): + raise ValueError("Full lifecycle requires native Steam, Electric and Base Metals") + for protected in (target[0], *target[2]): + require_disjoint(output, protected) + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + definition = lifecycle.fixtures() + helper_before = {path.name: digest(path) for path in (Path(lifecycle.__file__), Path(__file__))} + output.mkdir(parents=True, exist_ok=False) + report_path = output / "native115-lifecycle-results.json" + report = dict(status="RUNNING", started_at_utc=datetime.now(timezone.utc).isoformat(), + scope="independent-native115-movement-aware-lifecycle", + protocol="Inherited LifecycleRun operations, unchanged fixtures/oracles; bounded native loaded/tick readiness; genuine chunk events; no source process", + source=str(args.source.resolve()), sourceKey=args.source_key, + sourceManifest=str(args.source_manifest.resolve()), sourceManifestSha256=digest(args.source_manifest), + sourceHashes=str(args.source_hashes.resolve()), sourceHashesSha256=digest(args.source_hashes), + sourceRuntimeBefore=source_before, sourceWorldExpected=read_json(args.source_hashes), + sourceStateGates=history, targetManifest=str(args.target_manifest.resolve()), + targetManifestSha256=digest(args.target_manifest), targetProfile=read_json(args.target_manifest)["target115"], + helperSha256=helper_before, port=args.port, profiles={}) + report["load_stack_tracing"] = getattr(args, "trace_loads", False) + for path in (Path(lifecycle.__file__), Path(__file__)): + archive = output / "protocol-sources" / path.name + archive.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, archive) + write_json(output / "lifecycle-fixture.json", definition) + write_json(report_path, report) + runner = None + try: + for name, source in (("fresh115", None), ("original110-chained113-native115", args.source)): + runner = None + result = dict(status="RUNNING") + report["profiles"][name] = result + write_json(report_path, report) + try: + runtime = output / name + launcher = prepare(runtime, target, definition, args.port, source) + runner = NativeLifecycleRun(runtime, args.java8, launcher, definition) + if source is not None: + result["frozen_import"] = frozen_import(runner, source) + authorize_lifecycle(runtime) + + def progress(value): + result.update(value) + write_json(report_path, report) + + exercise(runner, name, progress, trace_loads=getattr(args, "trace_loads", False)) + except Exception as error: + result.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + print(f"{name}: FAIL: {error}", flush=True) + finally: + if runner is not None: + result["native_readiness"] = dict(file="native115-readiness.json", stages=len(runner.observations)) + try: + runner.close() + except Exception as error: + result.update(status="FAIL", shutdown_error=str(error)) + runtime = output / name + if (runtime / "input-jars.json").is_file(): + try: + verify_staged(runtime, target) + logs = sorted(runtime.glob("console-*.log")) + for log in logs: + require_native_boot(log) + result["native_boots"] = [log.name for log in logs] + result["forge_config_reload_notices"] = { + log.name: log.read_text(encoding="utf-8", errors="replace").count( + "[ne.mi.co.ForgeConfig/CORE]: Forge config just got changed on the file system!") + for log in logs} + except Exception as error: + result.update(status="FAIL", runtime_audit_error=str(error)) + write_json(report_path, report) + report["status"] = "PASS" if all(row["status"] == "PASS" for row in report["profiles"].values()) else "FAIL" + except BaseException as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + raise + finally: + if runner is not None: + try: + runner.close() + except Exception as error: + report.update(status="FAIL", shutdown_error=str(error)) + after = fingerprint(args.source.resolve()) + report["sourceRuntimeAfter"] = after + report["source_fixture_unchanged"] = after == source_before + report["sourceWorldAfter"] = fingerprint(args.source.resolve() / WORLD) + if after != source_before: + report.update(status="FAIL", source_error="Protected retained13 source changed") + helper_after = {path.name: digest(path) for path in (Path(lifecycle.__file__), Path(__file__))} + report["helpers_unchanged"] = helper_after == helper_before + if helper_after != helper_before: + report.update(status="FAIL", helper_error="Lifecycle code changed during execution") + report["completed_at_utc"] = datetime.now(timezone.utc).isoformat() + write_json(report_path, report) + print(report["status"] + ": " + str(report_path), flush=True) + return int(report["status"] != "PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--source-manifest", type=Path, required=True) + parser.add_argument("--source-key", default="target113") + parser.add_argument("--source-hashes", type=Path, required=True) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=25580) + parser.add_argument("--trace-loads", action="store_true", + help="Record native chunk-load caller stacks; does not change any lifecycle oracle or timeout") + return run(parser.parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_oil.py b/test-rig/migration/native115_oil.py new file mode 100644 index 0000000..52a47b0 --- /dev/null +++ b/test-rig/migration/native115_oil.py @@ -0,0 +1,363 @@ +"""Qualify native115 bucket/drain/pipe transport in a disposable pinned runtime. + +The original oil_transfer geometry and quantity/identity oracles are unchanged. +Only the initial empty construction and two native bucket placements may mutate +the fixture; subsequent boots must retain exactly 1000, then 2000 mB per lane. +""" + +import argparse +from copy import deepcopy +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +import socket +import time +import traceback + +from lifecycle import LifecycleRun, at, parse +from matrix import digest, fingerprint, stage, write_json +from native115 import (LAUNCHER, RIG, output_path, pinned_profile, + require_clean_log, require_disjoint) +import oil_transfer + + +MINERALOGY_VERSION = "6.0.1.115021" +MINERALOGY_SHA256 = "ADACF365CCAFF7091980AA92ED1FCC14892AA6CD4B10E82336C8512525341DFA" +BOOT_LABELS = ("oil-first-fill", "oil-restart-refill", "oil-second-restart") +CONFIG_NOTICE = "[ne.mi.co.ForgeConfig/CORE]: Forge config just got changed on the file system!" + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def fixtures(pins): + definition = deepcopy(oil_transfer.fixtures()) + mineralogy = [jar for jar in pins["jars"] if jar["id"] == "mineralogy"] + if mineralogy: + if (len(mineralogy) != 1 or mineralogy[0]["version"] != MINERALOGY_VERSION + or mineralogy[0]["sha256"].upper() != MINERALOGY_SHA256): + raise ValueError("Mineralogy oil lanes require the pinned native 6.0.1.115021 candidate") + for template in definition[:2]: + lane = deepcopy(template) + for pos in [lane["min"], lane["max"], lane["source"], lane["drain"], *lane["pipes"], lane["output"]]: + pos[0] += 256 + lane["witnesses"] = [lane["source"], lane["drain"], *lane["pipes"], lane["output"]] + lane.update(id="OIL-mineralogy-crude_oil-" + lane["tank"].split(":")[1], + fluid="mineralogy:crude_oil", bucket="mineralogy:crude_oil_bucket") + definition.append(lane) + require_bounds(definition) + return definition + + +def require_bounds(definition): + ids = set() + for lane in definition: + key, low, high = lane["id"], lane["min"], lane["max"] + if key in ids or not re.fullmatch(r"[A-Za-z0-9_-]+", key): + raise AssertionError("Invalid or duplicate oil lane ID") + ids.add(key) + if (len(low) != 3 or len(high) != 3 or low[0] < 1024 or low[2] < 1024 + or low[1] < 8 or high[1] > 240 + or any(not 0 <= b - a <= 31 for a, b in zip(low, high))): + raise AssertionError("Oil lane is outside original remote fixture bounds") + route = [lane["source"], lane["drain"], *lane["pipes"], lane["output"]] + if lane["witnesses"] != route or len({tuple(pos) for pos in route}) != len(route): + raise AssertionError("Oil lane witness geometry changed") + x, y, z = lane["source"] + walls = [[x + dx, y, z + dz] for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1))] + for pos in [*route, *walls]: + if len(pos) != 3 or not all(a <= value <= b for a, value, b in zip(low, pos, high)): + raise AssertionError("Oil witness or containment wall outside fixture bounds") + if y >= high[1] or any(sum(abs(a - b) for a, b in zip(p, q)) != 1 + for p, q in zip(route, route[1:])): + raise AssertionError("Oil route disconnected or missing bucket headroom") + for other in definition: + if other is not lane and all(a <= d and c <= b for a, b, c, d in + zip(low, high, other["min"], other["max"])): + raise AssertionError("Oil lane bounds overlap") + + +def require_document(snapshot, definition): + cases = snapshot["cases"] + if set(cases) != {lane["id"] for lane in definition}: + raise AssertionError("Oil snapshot lane IDs/count changed") + for lane in definition: + positions = [tuple(entry["pos"]) for entry in cases[lane["id"]]] + if len(set(positions)) != len(positions) or set(positions) != {tuple(p) for p in lane["witnesses"]}: + raise AssertionError("Oil snapshot has missing, duplicate or moved witnesses") + + +def require_native_boot(path): + require_clean_log(path) + text = path.read_text(encoding="utf-8", errors="replace") + for marker in ("Starting minecraft server version 1.15.2", + "Forge mod loading, version 31.2.57, for MC 1.15.2 with MCP 20200515.085601"): + if marker not in text: + raise AssertionError("Missing genuine native115 boot proof: " + marker) + # Forge 28 logs this one configuration-watcher notice at FATAL without + # throwing; every other FATAL remains a qualification failure. + failures = [line for line in text.splitlines() if + ("/FATAL]" in line and not line.endswith(CONFIG_NOTICE)) or any(token in line for token in ( + "Lifecycle probe failed", "ADVLIFECYCLE FAIL", "Unknown or incomplete command", + "Incorrect argument for command", "An unexpected error occurred trying to execute that command"))] + if failures: + raise AssertionError("Native oil runtime failure: " + repr(failures[:5])) + + +def verify_staged(runtime, pins, seed_before): + expected = sorted((Path(jar["path"]).name, jar["sha256"].upper()) for jar in pins["jars"]) + actual = sorted((jar.name, digest(jar)) for jar in (runtime / "mods").glob("*.jar")) + recorded = sorted((jar["file"], jar["sha256"].upper()) for jar in read_json(runtime / "input-jars.json")) + if len({name for name, _ in expected}) != len(expected) or actual != expected or recorded != expected: + raise AssertionError("Staged native jars differ from captured manifest pins") + if digest(runtime / LAUNCHER) != pins["launcher_sha256"].upper(): + raise AssertionError("Staged native launcher differs from captured manifest pin") + # The installed seed's complete libraries and root launch jars, not its + # worlds/config/mods, are the only seed content matrix.stage may copy. + expected_libraries = {str(Path(name).relative_to("libraries")): value for name, value in seed_before.items() + if Path(name).parts[0] == "libraries"} + if not expected_libraries or fingerprint(runtime / "libraries") != expected_libraries: + raise AssertionError("Staged native libraries differ from seed fingerprint") + expected_roots = {name: value for name, value in seed_before.items() + if len(Path(name).parts) == 1 and name.endswith(".jar") and "installer" not in name} + if {jar.name: digest(jar) for jar in runtime.glob("*.jar")} != expected_roots: + raise AssertionError("Staged root jars differ from seed fingerprint") + + +def prepare(runtime, profile, definition, port, pins, seed_before): + if profile[1] != LAUNCHER: + raise ValueError("Refusing a non-native115 oil runtime") + launcher = stage(runtime, profile) + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() + if not line.startswith(("server-port=", "generator-settings="))] + properties.write_text("\n".join(lines) + f"\nserver-port={port}\ngenerator-settings=\n", encoding="utf-8") + for marker in ("migration-operate.allow", "advantage-works.allow", "advantage-lifecycle.allow"): + (runtime / marker).touch() + write_json(runtime / "lifecycle-fixture.json", definition) + verify_staged(runtime, pins, seed_before) + return launcher + + +def construction_commands(definition): + commands = [] + for lane in definition: + commands.append("fill " + " ".join(map(str, lane["min"] + lane["max"])) + " minecraft:air replace") + blocks = [(p, "poweradvantage:fluid_pipe") for p in lane["pipes"]] + blocks += [(lane["drain"], "poweradvantage:fluid_drain"), (lane["output"], lane["tank"])] + x, y, z = lane["source"] + blocks += [([x + dx, y, z + dz], "minecraft:glass") for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1))] + commands.extend("setblock " + " ".join(map(str, pos)) + " " + block for pos, block in blocks) + return commands + + +class NativeOilRun(LifecycleRun): + def __init__(self, runtime, java, launcher, definition, pins, seed_before): + super().__init__(runtime, java, launcher, definition) + self.pins, self.seed_before = pins, seed_before + self.boots = [] + self.pending_construction = construction_commands(definition) + self.construction_sealed = False + self.bucket_permission = False + self.bucket_placements = 0 + + def boot(self, label): + if self.server is not None or self.launcher != LAUNCHER or self.boots != list(BOOT_LABELS[:len(self.boots)]): + raise AssertionError("Invalid native oil process lifecycle") + if len(self.boots) >= 3 or label != BOOT_LABELS[len(self.boots)]: + raise AssertionError("Only the three ordered native oil boots are permitted") + if self.boots and not self.construction_sealed: + raise AssertionError("Restart before initial construction was sealed") + verify_staged(self.runtime, self.pins, self.seed_before) + super().boot(label) + self.boots.append(label) + require_native_boot(self.runtime / f"console-{label}.log") + + def seal_construction(self): + if self.pending_construction or self.construction_sealed or len(self.boots) != 1: + raise AssertionError("Incomplete or repeated initial oil construction") + self.construction_sealed = True + + def permit_buckets(self): + if not self.construction_sealed or self.bucket_placements != len(self.boots) - 1 or len(self.boots) > 2: + raise AssertionError("Only one bucket placement after each preservation gate is permitted") + self.bucket_permission = True + + def command(self, command): + if self.server is None: + raise AssertionError("No native oil process is running") + if self.pending_construction and command == self.pending_construction[0]: + if len(self.boots) != 1 or self.construction_sealed: + raise AssertionError("Oil construction after initial baseline is forbidden") + self.pending_construction.pop(0) + elif command == "save-all flush": + pass + else: + match = re.fullmatch(r"advlifecycle (load|snapshot|place-buckets) [A-Za-z0-9_-]+", command) + if not match: + raise AssertionError("Forbidden native oil mutation: " + command) + if match[1] == "place-buckets": + if not self.bucket_permission or self.bucket_placements >= 2: + raise AssertionError("Bucket placement requires the empty/restored preservation gate") + self.bucket_permission = False + self.bucket_placements += 1 + super().command(command) + + +def exercise(runner, seconds, progress): + definition = runner.definition + phases = [] + + def record(phase, snapshot, expected): + require_document(snapshot, definition) + phases.append(dict(phase=phase, cases=oil_transfer.inspect(snapshot, definition, expected))) + progress(dict(phases=phases, full_process_restarts=len(runner.boots) - 1, + bucket_placements=runner.bucket_placements)) + + runner.boot(BOOT_LABELS[0]) + runner.probe("load", "oil-loaded") + for command in construction_commands(definition): + runner.command(command) + runner.seal_construction() + empty = runner.probe("snapshot", "oil-empty") + require_document(empty, definition) + for lane in definition: + for pos in (lane["drain"], lane["output"]): + if parse(at(empty, lane["id"], pos)).get("Tank", {}).get("Amount", 0) != 0: + raise AssertionError("Fixture was not empty: " + lane["id"]) + oil_transfer.require_same_machines(empty, empty, definition) + runner.permit_buckets() + runner.probe("place-buckets", "oil-first-buckets") + time.sleep(seconds) + first = runner.probe("snapshot", "oil-first-fill") + record("first-fill", first, 1000) + oil_transfer.require_same_machines(empty, first, definition) + runner.command("save-all flush") + runner.close() + + runner.boot(BOOT_LABELS[1]) + runner.probe("load", "oil-restart-loaded") + time.sleep(seconds) + restored = runner.probe("snapshot", "oil-restored") + record("restored", restored, 1000) + runner.permit_buckets() + runner.probe("place-buckets", "oil-refill-buckets") + time.sleep(seconds) + refilled = runner.probe("snapshot", "oil-refilled") + record("refilled", refilled, 2000) + oil_transfer.require_same_machines(restored, refilled, definition) + runner.command("save-all flush") + runner.close() + + runner.boot(BOOT_LABELS[2]) + runner.probe("load", "oil-second-restart-loaded") + time.sleep(seconds) + record("retained", runner.probe("snapshot", "oil-retained"), 2000) + runner.command("save-all flush") + runner.close() + if runner.boots != list(BOOT_LABELS) or runner.bucket_placements != 2: + raise AssertionError("Incomplete native oil restart/refill protocol") + + +def run(args): + if not 1 <= args.port <= 65535 or not 2 <= args.seconds <= 120 or not args.java8.is_file(): + raise ValueError("Supply Java 8, a TCP port, and 2..120 seconds per observation interval") + output = output_path(args.output) + if not output.name.startswith("native115-oil-"): + raise ValueError("Use a new native115-oil-* output under test-rig/build") + manifest = args.target_manifest.resolve() + require_disjoint(output, manifest) + manifest_bytes = manifest.read_bytes() + manifest_hash = hashlib.sha256(manifest_bytes).hexdigest().upper() + pins = json.loads(manifest_bytes)["target115"] + profile = pinned_profile(manifest, "target115") + if digest(manifest) != manifest_hash: + raise AssertionError("Target manifest changed during preflight") + for protected in (profile[0], *profile[2]): + require_disjoint(output, protected) + definition = fixtures(pins) + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + seed_before = fingerprint(profile[0]) + helpers = [Path(oil_transfer.__file__), Path(__file__), Path(__file__).with_name("lifecycle.py")] + helpers_before = {path.name: digest(path) for path in helpers} + output.mkdir(parents=True, exist_ok=False) + (output / "target-manifest.json").write_bytes(manifest_bytes) + report_path = output / "native115-oil-results.json" + runtime = output / "target115" + report = dict(status="RUNNING", started_at_utc=datetime.now(timezone.utc).isoformat(), + scope="native115-buckets-descending-pipes-two-full-restarts", + protocol="Initial empty construction only; native bucket sources twice; no tile.read() restores/API offers/manual tank refills/resets", + targetManifest=str(manifest), targetManifestSha256=manifest_hash, targetProfile=pins, + seed=str(profile[0]), seedFingerprintBefore=seed_before, helperSha256=helpers_before, + port=args.port, seconds=args.seconds, laneCount=len(definition), phases=[], full_process_restarts=0, + mineralogy="INCLUDED" if len(definition) == 6 else "ABSENT_FROM_MANIFEST") + write_json(output / "oil-fixture.json", definition) + write_json(report_path, report) + runner = None + try: + launcher = prepare(runtime, profile, definition, args.port, pins, seed_before) + report["stagedInputs"] = read_json(runtime / "input-jars.json") + runner = NativeOilRun(runtime, args.java8, launcher, definition, pins, seed_before) + + def progress(value): + report.update(value) + write_json(report_path, report) + print("Oil " + value["phases"][-1]["phase"] + ": PASS, " + str(len(definition)) + " lanes", flush=True) + + exercise(runner, args.seconds, progress) + report["status"] = "PASS" + except Exception as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + except BaseException as error: + report.update(status="FAIL", failure=repr(error), traceback=traceback.format_exc()) + raise + finally: + if runner is not None: + try: + runner.close() + except Exception as error: + report.update(status="FAIL", shutdown_failure=str(error)) + try: + verify_staged(runtime, pins, seed_before) + logs = sorted(runtime.glob("console-*.log")) + for log in logs: + require_native_boot(log) + report["nativeBoots"] = [log.name for log in logs] + report["forge_config_reload_notices"] = { + log.name: log.read_text(encoding="utf-8", errors="replace").count(CONFIG_NOTICE) for log in logs} + if report["status"] == "PASS" and len(logs) != 3: + raise AssertionError("Three complete native115 boot logs are required") + except Exception as error: + report.update(status="FAIL", runtime_audit_failure=str(error)) + try: + after = fingerprint(profile[0]) + report["seedFingerprintAfter"] = after + report["seed_unchanged"] = after == seed_before + report["helpers_unchanged"] = {path.name: digest(path) for path in helpers} == helpers_before + report["manifest_unchanged"] = digest(manifest) == manifest_hash + if not all(report[key] for key in ("seed_unchanged", "helpers_unchanged", "manifest_unchanged")): + raise AssertionError("Protected seed, helper or manifest changed") + except Exception as error: + report.update(status="FAIL", preservation_failure=str(error)) + report["completed_at_utc"] = datetime.now(timezone.utc).isoformat() + write_json(report_path, report) + print(report["status"] + ": " + str(report_path), flush=True) + return int(report["status"] != "PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=25576) + parser.add_argument("--seconds", type=int, default=8) + return run(parser.parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_partial.py b/test-rig/migration/native115_partial.py new file mode 100644 index 0000000..0613613 --- /dev/null +++ b/test-rig/migration/native115_partial.py @@ -0,0 +1,362 @@ +"""Native 1.15 transport for unchanged partial-network and pipe-bridge oracles.""" + +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +import shutil +import socket +import time +import traceback + +from audit_pipe_only import bridge_result +import extended_lifecycle as extended +import pipe_only_lifecycle as bridge +from lifecycle import at, counts, parse +from matrix import digest, fingerprint, write_json +from native115 import output_path, pinned_profile, require_disjoint +from native115_lifecycle import (NativeLifecycleRun, authorize_lifecycle, frozen_import, + prepare, read_json, require_native_boot, verify_source, + verify_staged) + + +def selected_chunks(definition, group=None): + if group is None: + return {(x, z) for f in definition + for x in range(f["min"][0] >> 4, (f["max"][0] >> 4) + 1) + for z in range(f["min"][2] >> 4, (f["max"][2] >> 4) + 1)} + return {tuple(p) for f in definition for p in f.get("groups", {}).get(group, [])} + + +def subset_load_issues(before, current, selected): + old = {tuple(c["pos"]): c for c in before["chunks"]} + chunks = {tuple(c["pos"]): c for c in current["chunks"]} + events = {tuple(e["pos"]) for e in current.get("chunkEvents", []) if e["operation"] == "load"} + errors = [] + for pos in selected: + a, b = old.get(pos), chunks.get(pos) + if a is None or b is None or not b["loaded"]: + errors.append("selected chunk not loaded: " + str(pos)) + elif pos not in events or b["loads"] <= 0 or (not a["loaded"] and b["loads"] <= a["loads"]): + errors.append("missing actual Chunk.Load: " + str(pos)) + for rows in current["cases"].values(): + for row in rows: + if (row["pos"][0] >> 4, row["pos"][2] >> 4) in selected and not row.get("loaded"): + errors.append("selected witness not loaded: " + str(row["pos"])) + return errors + + +def subset_unload_pending(before, current, selected): + old = {tuple(c["pos"]): c for c in before["chunks"]} + chunks = {tuple(c["pos"]): c for c in current["chunks"]} + events = {tuple(e["pos"]) for e in current.get("chunkEvents", []) if e["operation"] == "unload"} + absent = all(pos in chunks and not chunks[pos]["loaded"] for pos in selected) + pending = [str(pos) for pos in selected if pos not in old or pos not in chunks + or pos not in events or chunks[pos]["unloads"] <= old[pos]["unloads"]] + return absent, pending + + +class NativePartialRun(NativeLifecycleRun): + """Select actual snapshots by chunk callbacks/ticks, never by machine results.""" + + def __init__(self, *args): + super().__init__(*args) + self._released_groups = {} + + def _canonical(self, selected, label): + root = self.runtime / "advantage-works-results" + destination = root / ("lifecycle-" + label + ".json") + if destination.exists(): + raise FileExistsError(destination) + shutil.copy2(root / ("lifecycle-" + selected + ".json"), destination) + + def _load_ready(self, operation, label): + selected = selected_chunks(self.definition, None if operation == "load" else operation[5:]) + if not selected: + raise ValueError("Unknown fixture chunk group: " + operation) + before = self._raw_probe("snapshot", label + "-native-before") + current_label = label + "-native-initial" + current = self._raw_probe(operation, current_label) + initial_tick, loaded_tick, started = current["worldTime"], None, time.monotonic() + record = dict(label=label, phase="native-subset-load-readiness", operation=operation, + selected_chunks=sorted(selected), samples=[], max_seconds=20, max_ticks=160) + self.observations.append(record) + try: + for attempt in range(200): + errors = subset_load_issues(before, current, selected) + tick = current["worldTime"] + if tick < initial_tick: + raise AssertionError("Native worldTime moved backwards") + loaded_tick = None if errors else (tick if loaded_tick is None else loaded_tick) + record["samples"].append(dict(label=current_label, world_time=tick, issues=errors, + loaded_since_world_time=loaded_tick)) + if loaded_tick is not None and tick - loaded_tick >= 1: + self._canonical(current_label, label) + record.update(status="PASS", selected_label=current_label, canonical_label=label) + self._record() + return current + if time.monotonic() - started >= 20 or tick - initial_tick >= 160 or attempt == 199: + raise TimeoutError("Native selected chunks did not load and advance a real tick: " + label) + self._record() + time.sleep(0.01) + current_label = label + f"-native-pending-{attempt + 1:03}" + current = self._raw_probe("snapshot", current_label) + except BaseException as error: + record.update(status="FAIL", failure=str(error)) + self._record() + raise + + def _snapshot_ready(self, operation, label): + current_label = label + "-native-initial" + current = self._raw_probe(operation, current_label) + record = dict(label=label, phase="native-subset-unload-callback-readiness", samples=[]) + self.observations.append(record) + for attempt in range(45): + pending_groups = [] + for group, context in self._released_groups.items(): + absent, pending = subset_unload_pending(context["before"], current, context["selected"]) + if absent and pending and time.monotonic() < context["deadline"]: + pending_groups.append(group) + record["samples"].append(dict(label=current_label, world_time=current["worldTime"], + pending_groups=pending_groups)) + if not pending_groups or attempt == 44: + break + self._record() + deadline = min(self._released_groups[g]["deadline"] for g in pending_groups) + time.sleep(min(1, max(0, deadline - time.monotonic()))) + current_label = label + f"-native-pending-{attempt + 1:02}" + current = self._raw_probe("snapshot", current_label) + self._canonical(current_label, label) + record.update(status="OBSERVED", selected_label=current_label, canonical_label=label) + self._record() + return current + + def probe(self, operation, label): + if operation == "load" or operation.startswith("load-"): + result = self._load_ready(operation, label) + if operation == "load": + self._released_groups.clear() + else: + self._released_groups.pop(operation[5:], None) + return result + if operation.startswith("release-"): + group = operation[8:] + selected = selected_chunks(self.definition, group) + if not selected: + raise ValueError("Unknown fixture chunk group: " + group) + before = self._raw_probe("snapshot", label + "-native-before") + result = self._raw_probe(operation, label) + self._released_groups[group] = dict(before=before, selected=selected, + deadline=time.monotonic() + 45) + return result + if self._released_groups and operation in ("snapshot", "isolated"): + return self._snapshot_ready(operation, label) + return super().probe(operation, label) + + def boot(self, label): + super().boot(label) + require_native_boot(self.runtime / f"console-{label}.log") + if label == "blocked-restart": + self.probe("load", "blocked-restart-native-restored") + + +class NativeExtendedRun(extended.ExtendedRun, NativePartialRun): + pass + + +def orders(runner, process=False): + results = [] + for order in (("machine", "supply"), ("supply", "machine")): + label = "order-" + "-".join(order) + before = runner.probe("snapshot", label + "-before") + runner.probe("release-supply", label + "-release-supply") + runner.probe("release-machine", label + "-release-machine") + time.sleep(5) + absent = runner.probe("snapshot", label + "-absent") + first = runner.probe("load-" + order[0], label + "-first") + time.sleep(2) + settled = runner.probe("snapshot", label + "-settled") + loaded = runner.probe("load-" + order[1], label + "-loaded") + results.append(dict(label=label, order=list(order), cases=extended.order_results( + runner.definition, before, absent, first, settled, loaded, order))) + if process: + runner.replenish() + time.sleep(50) + runner.processing(label + "-resumed", 5000) + return results + + +def exercise_partial(runner, progress): + runner.boot("partial-trace") + runner.build() + time.sleep(50) + runner.processing("commissioned", 1000) + runner.probe("trace-loads", "trace-enabled") + result = dict(status="RUNNING", partial_networks=[], reload_orders=[]) + for cycle, group in enumerate(("machine", "supply"), 1): + result["partial_networks"].append(runner.partial(group, cycle)) + progress(result) + result["reload_orders"] = orders(runner) + result["status"] = "PASS" if all(c["status"] == "PASS" for r in + result["partial_networks"] + result["reload_orders"] for c in r["cases"].values()) else "FAIL" + runner.command("save-all flush") + runner.close() + progress(result) + return result + + +def exercise_bridge(runner, progress): + runner.boot("pipe-only") + runner.probe("load", "loaded") + for f in runner.definition: + runner.command("fill " + " ".join(map(str, f["min"] + f["max"])) + " minecraft:air replace") + fluid, steam = f["id"] == "C-FLUID", f["id"] == "C-STEAM" + pipe = "poweradvantage:fluid_pipe" if fluid else "steamadvantage:steam_pipe" if steam else "electricadvantage:electric_conduit" + runner.block(f["witnesses"][0], pipe if fluid else "poweradvantage:infinite_" + ("steam" if steam else "electricity")) + for pos in f["pipes"]: + runner.block(pos, pipe) + runner.block(f["witnesses"][1], "poweradvantage:fluid_metal_tank" if fluid else + "steamadvantage:steam_furnace" if steam else "electricadvantage:electric_furnace") + if steam: + runner.inventory(f["witnesses"][1], 0, "minecraft:coal", 64) + runner.probe("trace-loads", "trace-enabled") + time.sleep(2) + runner.probe("snapshot", "commissioned") + result = dict(status="RUNNING", cycles=[]) + for cycle in range(3): + before = runner.probe("snapshot", f"bridge-{cycle}-before") + runner.command("save-all flush") + runner.probe("release-bridge", f"bridge-{cycle}-released") + time.sleep(3) + absent = runner.probe("isolated", f"bridge-{cycle}-isolated") + runner.probe("offer-blocked", f"bridge-{cycle}-blocked-offer") + time.sleep(3) + confirmed = runner.probe("snapshot", f"bridge-{cycle}-confirmed") + loaded = runner.probe("load-bridge", f"bridge-{cycle}-loaded") + runner.probe("offer", f"bridge-{cycle}-reconnected-offer") + for f in runner.definition: + if f["id"] == "C-FLUID": + continue + machine, steam = f["witnesses"][1], f["id"] == "C-STEAM" + for slot in range(1 if steam else 0, 7 if steam else 10): + runner.inventory(machine, slot, "minecraft:air", 1) + runner.inventory(machine, 1 if steam else 0, "minecraft:iron_ore", 4) + time.sleep(50) + resumed = runner.probe("snapshot", f"bridge-{cycle}-resumed") + verdict = bridge_result(runner.definition, before, absent, confirmed, loaded, resumed, cycle) + result["cycles"].append(dict(cycle=cycle, **verdict)) + progress(result) + print(f"{runner.runtime.name}: bridge cycle {cycle + 1}: {verdict['status']}", flush=True) + result["status"] = "PASS" if all(c["status"] == "PASS" for c in result["cycles"]) else "FAIL" + runner.command("save-all flush") + runner.close() + progress(result) + return result + + +def common_parser(description): + parser = argparse.ArgumentParser(description=description) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--source-manifest", type=Path, required=True) + parser.add_argument("--source-key", default="target113") + parser.add_argument("--source-hashes", type=Path, required=True) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, required=True, choices=(25580, 25581, 25582, 25583)) + return parser + + +def campaign(args, name, definition, exercise, runner_type=NativeExtendedRun): + output = output_path(args.output) + for protected in (args.source, args.source_manifest, args.source_hashes, args.target_manifest): + require_disjoint(output, protected) + if not args.java8.is_file(): + raise FileNotFoundError(args.java8) + source_before, history = verify_source(args) + target = pinned_profile(args.target_manifest.resolve(), "target115") + for protected in (target[0], *target[2]): + require_disjoint(output, protected) + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + output.mkdir(parents=True) + report_path = output / ("native115-" + name + "-results.json") + helpers = [Path(__file__), Path(extended.__file__), Path(bridge.__file__), + Path(__file__).with_name("lifecycle.py"), Path(__file__).with_name("native115_lifecycle.py"), + Path(__file__).with_name("native115_extended.py")] + hashes = {path.name: digest(path) for path in helpers if path.is_file()} + report = dict(status="RUNNING", campaign=name, started_at_utc=datetime.now(timezone.utc).isoformat(), + scope="native115 fresh and hash-protected retained13 copy; unchanged historical semantic oracles", + source=str(args.source.resolve()), sourceRuntimeBefore=source_before, sourceStateGates=history, + sourceManifestSha256=digest(args.source_manifest), sourceHashesSha256=digest(args.source_hashes), + targetManifest=str(args.target_manifest.resolve()), targetManifestSha256=digest(args.target_manifest), + targetProfile=read_json(args.target_manifest)["target115"], helpersBefore=hashes, port=args.port, profiles={}) + for path in helpers: + if path.is_file(): + archive = output / "protocol-sources" / path.name + archive.parent.mkdir(exist_ok=True) + shutil.copy2(path, archive) + write_json(output / "lifecycle-fixture.json", definition) + write_json(report_path, report) + try: + for label, source in (("fresh115", None), ("original110-chained113-native115", args.source)): + runtime, runner = output / label, None + result = report["profiles"][label] = dict(status="RUNNING") + write_json(report_path, report) + try: + launcher = prepare(runtime, target, definition, args.port, source) + runner = runner_type(runtime, args.java8, launcher, definition) + if source is not None: + result["frozen_import"] = frozen_import(runner, source) + authorize_lifecycle(runtime) + def progress(value): + result.update(value) + write_json(report_path, report) + exercise(runner, progress) + except BaseException as error: + result.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + print(label + ": FAIL: " + str(error), flush=True) + finally: + if runner is not None: + try: + runner.close() + except BaseException as error: + result.update(status="FAIL", shutdown_error=str(error)) + if (runtime / "input-jars.json").is_file(): + try: + verify_staged(runtime, target) + logs = sorted(runtime.glob("console-*.log")) + for log in logs: + require_native_boot(log) + text = log.read_text(encoding="utf-8", errors="replace") + if "Stopping server" not in text: + raise AssertionError("Missing graceful shutdown: " + log.name) + result["native_boots"] = [log.name for log in logs] + result["keep_up_warnings"] = {log.name: log.read_text(encoding="utf-8").count("Can't keep up!") for log in logs} + except BaseException as error: + result.update(status="FAIL", runtime_audit_error=str(error)) + write_json(report_path, report) + report["status"] = "PASS" if all(p["status"] == "PASS" for p in report["profiles"].values()) else "FAIL" + finally: + report["sourceRuntimeAfter"] = fingerprint(args.source.resolve()) + report["source_fixture_unchanged"] = report["sourceRuntimeAfter"] == source_before + report["helpersAfter"] = {path.name: digest(path) for path in helpers if path.is_file()} + report["helpers_unchanged"] = hashes == report["helpersAfter"] + if not report["source_fixture_unchanged"] or not report["helpers_unchanged"]: + report["status"] = "FAIL" + report["completed_at_utc"] = datetime.now(timezone.utc).isoformat() + write_json(report_path, report) + print(report["status"] + ": " + str(report_path), flush=True) + return int(report["status"] != "PASS") + + +def main(): + parser = common_parser(__doc__) + parser.add_argument("--campaign", choices=("partial", "bridge"), required=True) + args = parser.parse_args() + definition = [f for f in extended.fixtures() if "groups" in f] if args.campaign == "partial" else bridge.fixtures() + return campaign(args, args.campaign, definition, exercise_partial if args.campaign == "partial" else exercise_bridge) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_partial_diagnostic.py b/test-rig/migration/native115_partial_diagnostic.py new file mode 100644 index 0000000..44e5bd7 --- /dev/null +++ b/test-rig/migration/native115_partial_diagnostic.py @@ -0,0 +1,30 @@ +"""Empty-bay control for native partial-unload fixture ticket interference.""" + +import time + +import extended_lifecycle +from native115_partial import campaign, common_parser, orders + + +def exercise(runner, progress): + runner.boot("partial-empty-control") + runner.probe("load", "loaded") + for f in runner.definition: + runner.command("fill " + " ".join(map(str, f["min"] + f["max"])) + " minecraft:air replace") + runner.probe("trace-loads", "trace-enabled") + result = dict(status="RUNNING", purpose="empty bays; no Advantage networks or machines constructed", partial_networks=[]) + for cycle, group in enumerate(("machine", "supply"), 1): + result["partial_networks"].append(runner.partial(group, cycle)) + progress(result) + result["reload_orders"] = orders(runner) + result["status"] = "PASS" if all(c["status"] == "PASS" for r in + result["partial_networks"] + result["reload_orders"] for c in r["cases"].values()) else "FAIL" + runner.command("save-all flush") + runner.close() + progress(result) + return result + + +if __name__ == "__main__": + args = common_parser(__doc__).parse_args() + raise SystemExit(campaign(args, "empty-control", [f for f in extended_lifecycle.fixtures() if "groups" in f], exercise)) diff --git a/test-rig/migration/native115_player_layout.py b/test-rig/migration/native115_player_layout.py new file mode 100644 index 0000000..3880605 --- /dev/null +++ b/test-rig/migration/native115_player_layout.py @@ -0,0 +1,119 @@ +"""Native destinations for the historical player-only bag/Ender layout oracle.""" + +import argparse +import json +from pathlib import Path +import shutil +import socket + +import actors +from dimensions import exact_item_differences +from matrix import WORLD, Server, copy_fixture, digest, fingerprint, write_json +from native115 import output_path, pinned_profile, require_clean_log, require_disjoint +from native115_actors import baseline, command, raw +from player_layout import player_verdict + + +ROUTES = ("original110", "maintained110", "maintained112", "original110-chained-113", + "maintained110-direct-113", "maintained112-direct-113") + + +def verdict(expected, observed): + result = player_verdict(expected, observed) + extra = exact_item_differences({key: expected[key] for key in ("file", "adapter")}, observed) + result["differences"].extend(extra) + if extra: + result["status"] = "FAIL" + return result + + +def audit(root): + summary = json.loads((root / "summary.json").read_text()) + findings = [] + manifest = Path(summary["targetManifest"]) + if digest(manifest) != summary["targetManifestSha256"]: + findings.append("Target manifest changed") + pinned_profile(manifest, "target115") + for route in summary["results"]: + runtime = root / route + provenance = json.loads((runtime / "source-provenance.json").read_text()) + source = Path(provenance["source"]) + if fingerprint(source) != provenance["fingerprint"]: + findings.append(route + ": protected source changed") + expected, _ = baseline(source, "actors") + for jar in json.loads((runtime / "input-jars.json").read_text()): + if digest(runtime / "mods" / jar["file"]) != jar["sha256"]: + findings.append(route + ": staged jar changed") + for label in ("converted", "restarted"): + state = actors.capture(runtime, label, runtime / "actor-evidence" / label) + current = {"player": verdict(expected, state), "frozen": actors.freeze_result(raw(runtime, "actors", label))} + if current != summary["results"][route][label] or any(value["status"] != "PASS" for value in current.values()): + findings.append(route + "/" + label + ": raw player/freeze replay differs or fails") + require_clean_log(runtime / ("console-" + label + ".log")) + return {"status": "FAIL" if findings else "PASS", "routes": len(summary["results"]), "findings": findings, + "scope": "Player storage only; no actor release or turbine operation assertion"} + + +def run(args): + root = output_path(args.output) + require_disjoint(root, args.fixtures) + target = pinned_profile(args.target_manifest.resolve(), "target115") + with socket.socket() as port: + port.bind(("127.0.0.1", args.port)) + root.mkdir(parents=True) + results = {} + for route in ROUTES: + source = args.fixtures / route + before = fingerprint(source) + approved = source / "fixture-hashes.json" + if approved.is_file() and fingerprint(source / WORLD) != json.loads(approved.read_text()): + raise AssertionError("Protected layout fixture changed") + expected, source_label = baseline(source, "actors") + runtime = root / route + launcher = actors.boot(runtime, target, args.java8, args.port) + copy_fixture(source, runtime) + shutil.copy2(source / "actor-fixture.json", runtime / "actor-fixture.json") + write_json(runtime / "source-provenance.json", {"source": str(source.resolve()), "fingerprint": before, "checkpoint": source_label}) + checks = {} + try: + for label in ("converted", "restarted"): + server = Server(runtime, args.java8, launcher, label) + try: + state = command(server, runtime, "actors", "read", label) + checks[label] = {"player": verdict(expected, state), "frozen": actors.freeze_result(raw(runtime, "actors", label))} + finally: + server.close() + require_clean_log(runtime / ("console-" + label + ".log")) + finally: + write_json(runtime / "player-comparison.json", checks) + if fingerprint(source) != before: + raise AssertionError("Protected player-layout source changed") + results[route] = checks + write_json(root / "results.json", results) + print(route + ": " + ", ".join(label + "=" + check["player"]["status"] for label, check in checks.items()), flush=True) + status = "FAIL" if any(value["status"] != "PASS" for checks in results.values() for phase in checks.values() for value in phase.values()) else "PASS" + write_json(root / "summary.json", {"status": status, "results": results, "sourceFixtures": "UNCHANGED", + "targetManifest": str(args.target_manifest.resolve()), "targetManifestSha256": digest(args.target_manifest), + "scope": "Unchanged historical player-only oracle plus exact item NBT and genuinely frozen actor guard"}) + return int(status != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--audit", type=Path) + parser.add_argument("--fixtures", type=Path) + parser.add_argument("--target-manifest", type=Path) + parser.add_argument("--java8", type=Path) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, choices=range(25584, 25588)) + args = parser.parse_args() + if args.audit: + if args.output.exists(): + raise FileExistsError(args.output) + result = audit(args.audit.resolve()) + write_json(args.output, result) + print(result["status"] + ": " + str(result["routes"]) + " routes; " + str(len(result["findings"])) + " integrity findings") + raise SystemExit(int(result["status"] != "PASS")) + if any(value is None for value in (args.fixtures, args.target_manifest, args.java8, args.port)): + parser.error("Running requires fixtures, target manifest, Java 8 and port") + raise SystemExit(run(args)) diff --git a/test-rig/migration/native115_recipes.py b/test-rig/migration/native115_recipes.py new file mode 100644 index 0000000..2b72137 --- /dev/null +++ b/test-rig/migration/native115_recipes.py @@ -0,0 +1,319 @@ +"""Native 1.15 recipe exports and progression on new hash-pinned disposable servers. + +Historical campaign modules supply assertion data only. No legacy profile, seed, +world, artifact fallback or historical result is used as native runtime evidence. +""" + +import argparse +from collections import Counter +from datetime import datetime, timezone +import json +from pathlib import Path +import re +import socket +import zipfile + +import nbtlib + +from matrix import WORLD, Server, digest, fingerprint, stage, write_json +from native115 import RIG, output_path, pinned_profile, require_clean_log, require_disjoint +from progression import fixture as progression_fixture, validate_tags +from recipe_unlocks import MATERIALS + + +MOD_COUNTS = {"poweradvantage": 35, "steamadvantage": 24, "electricadvantage": 50} +RECIPE_TYPES = {"minecraft:crafting_shaped", "minecraft:crafting_shapeless", "minecraft:smelting"} +UNLOCK_BOOTS = ( + ("NORMAL", "normal", ("sweep", "persist")), + ("NORMAL", "normal-restart", ("restore",)), + ("TECH_PROGRESSION", "tech", ("restore", "sweep", "persist")), + ("APOCALYPTIC", "apocalyptic", ("restore", "sweep", "persist")), + ("NORMAL", "normal-return", ("restore",)), +) +MODES = ("NORMAL", "TECH_PROGRESSION", "APOCALYPTIC") + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def recipe_fixture(jars): + recipes = [] + sources = {} + for path in jars: + with zipfile.ZipFile(path) as jar: + for mod in MOD_COUNTS: + prefix = f"data/{mod}/recipes/" + names = sorted(name for name in jar.namelist() if name.startswith(prefix) + and name.endswith(".json") and "/" not in name[len(prefix):]) + for name in names: + recipe = json.loads(jar.read(name)) + if recipe["type"] not in RECIPE_TYPES: + continue + stem = Path(name).stem + advancement_name = f"data/{mod}/advancements/recipes/{stem}.json" + advancement = json.loads(jar.read(advancement_name)) + predicates = {} + for key, criterion in advancement["criteria"].items(): + if criterion["trigger"] != "minecraft:inventory_changed": + continue + items = criterion["conditions"]["items"] + if len(items) != 1 or set(items[0]) not in ({"item"}, {"tag"}): + raise AssertionError(f"Unsupported inventory predicate: {advancement_name}/{key}") + predicates[key] = items[0] + if not predicates: + raise AssertionError(f"No ingredient-discovery predicates: {advancement_name}") + identity = f"{mod}:{stem}" + if identity in sources: + raise AssertionError(f"Duplicate packaged recipe: {identity}") + sources[identity] = {"jar": str(path), "recipe": name, "advancement": advancement_name} + recipes.append({"id": identity, "ingredients": predicates}) + counts = Counter(row["id"].split(":")[0] for row in recipes) + if counts != MOD_COUNTS: + raise AssertionError(f"Packaged recipe inventory changed: {dict(counts)}; expected {MOD_COUNTS}") + return {"recipes": recipes, "materials": MATERIALS}, sources + + +def native_target(manifest): + entry = read_json(manifest)["target115"] + required = {"advantageworkstest", "poweradvantage", "steamadvantage", "electricadvantage", "basemetals", "orespawn"} + if {jar["id"] for jar in entry["jars"]} != required: + raise ValueError("This campaign requires exactly the native harness and five packaged mods") + target = pinned_profile(manifest, "target115") + if any("deobf" in path.name.lower() for path in target[2]): + raise ValueError("Use packaged native jars, not development candidates") + return target + + +def require_free_port(port): + with socket.socket() as check: + check.bind(("127.0.0.1", port)) + + +def stage_native(runtime, target, port, campaign): + require_free_port(port) + launcher = stage(runtime, target) + # Replace only the disposable stage's legacy generator and port defaults. + props = dict(line.split("=", 1) for line in (runtime / "server.properties").read_text().splitlines() if "=" in line) + props.update({"server-port": str(port), "generator-settings": "", "level-type": "FLAT"}) + (runtime / "server.properties").write_text("".join(f"{key}={value}\n" for key, value in props.items()), encoding="utf-8") + marker = "advantage-recipes.allow" if campaign == "unlock" else "advantage-progression.allow" + (runtime / marker).touch() + if digest(runtime / launcher) != digest(target[0] / launcher): + raise AssertionError("Staged launcher differs from native input") + for jar in target[2]: + if digest(runtime / "mods" / jar.name) != digest(jar): + raise AssertionError(f"Staged mod differs from pinned input: {jar}") + if (runtime / WORLD).exists(): + raise AssertionError("Staging must not import an existing world") + return launcher + + +def configure(runtime, mode, rubber=True): + config = runtime / "config/poweradvantage-common.toml" + config.parent.mkdir(exist_ok=True) + config.write_text(f'[options]\nrecipeMode = "{mode}"\nplasticEqualsRubber = {str(rubber).lower()}\n', encoding="utf-8") + + +def validate_unlock(result, definition, operation): + expected = {row["id"] for row in definition["recipes"]} + rows = result["cases"] + if (result["status"] != "PASS" or len(rows) != len(expected) + or {row["id"] for row in rows} != expected or result["potatoCraftingCases"] != 59): + raise AssertionError("Incomplete native unlock/potato-crafting report") + if not result["bookRecipes"]: + raise AssertionError("Native server exported no recipe-book recipes") + if operation == "persist" and result["additionalMaterials"]: + raise AssertionError("Human materials checklist has gaps: " + str(result["additionalMaterials"])) + allowed = {"PASS", "SKIP_NO_INGREDIENT_PROVIDER"} + if any(row["status"] not in allowed for row in rows): + raise AssertionError("Failed native discovery case") + return {"cases": len(rows), "active": sum(row["recipeActive"] for row in rows), + "passed": sum(row["status"] == "PASS" for row in rows), + "skipped": sum(row["status"] != "PASS" for row in rows), "potatoCraftingCases": 59} + + +def validate_progression(result, mode, rubber): + validate_tags(result, mode, rubber) + if result["mode"] != mode or result["plasticEqualsRubber"] != rubber: + raise AssertionError("Native progression ignored its requested mode/rubber configuration") + if result["status"] != "PASS" or result["blocked"] != 0 or result["blockedRecipes"]: + raise AssertionError("Native manufacture blocked: " + str(result.get("blockedRecipes")) + "; " + + str([row["id"] for row in result["targets"] if row["status"] == "BLOCKED"])) + if not result["targets"] or not result["reachable"] or not result["executedRecipes"] or not result["processWitnesses"]: + raise AssertionError("Empty native manufacture evidence") + if not result["lootDataPackOverrideVerified"]: + raise AssertionError("Player loot override was not preserved") + + +def verify_level(runtime, entry): + level_path = runtime / WORLD / "level.dat" + level = nbtlib.load(level_path) + if str(level["Data"]["Version"]["Name"]) != "1.15.2": + raise AssertionError("Server did not write a native 1.15.2 level header") + mods = {str(row["ModId"]): str(row["ModVersion"]) for row in level["fml"]["LoadingModList"]} + if mods.get("forge") != "31.2.57" or any(mods.get(jar["id"]) != jar["version"] for jar in entry["jars"]): + raise AssertionError(f"Saved native mod identities differ from the target manifest: {mods}") + registry = level["fml"]["Registries"] + if any(not registry.get(name, {}).get("ids") for name in ("minecraft:item", "minecraft:block")): + raise AssertionError("Missing native item/block registry snapshot; legacy plural registry names are not valid") + return {"path": str(level_path), "sha256": digest(level_path), "minecraft": "1.15.2", "mods": mods} + + +def audit_unlock_log(path, previous_recipes, current_recipes): + """Witness vanilla's exact mode-conditional saved-book pruning, not arbitrary errors.""" + removed = set(previous_recipes) - set(current_recipes) + observed = set() + events = [] + for original in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = re.sub(r"\x1b\[[0-9;]*m", "", original) + if "/ERROR]" not in line and not any(token in line for token in ( + "NoSuchFieldError", "NoSuchMethodError", "Failed to reload data packs", "Encountered an unexpected exception")): + continue + match = re.search(r"\[minecraft/ServerRecipeBook\]: Tried to load unrecognized recipe: ([a-z0-9_]+:[a-z0-9_./-]+) removed now\.$", line) + if not match or match[1] not in removed: + raise AssertionError(f"Unexpected native recipe runtime error: {original}") + observed.add(match[1]) + 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)}") + return {"expected_removed_ids": sorted(removed), "observed_removed_ids": sorted(observed), "events": events} + + +def unlock_campaign(root, target, entry, java, port, definition, report): + runtime = root / "unlock" + launcher = stage_native(runtime, target, port, "unlock") + write_json(runtime / "recipe-fixture.json", definition) + previous_recipes = set() + for mode, label, operations in UNLOCK_BOOTS: + configure(runtime, mode) + row = {"mode": mode, "label": label, "operations": [], "status": "RUNNING"} + report["unlock_boots"].append(row) + write_json(root / "native115-recipes-results.json", report) + print(f"Native115 unlock boot {label}: {', '.join(operations)}", flush=True) + server = Server(runtime, java, launcher, label) + try: + for operation in operations: + name = f"{label}-{operation}" + server.send(f"advrecipes {operation} {name}") + server.wait(f"ADVRECIPES COMPLETE {operation} {name}", 120) + result = read_json(runtime / f"recipes-{name}.json") + counts = validate_unlock(result, definition, operation) + packet = runtime / f"recipes-{name}.bin" + if not packet.is_file() or packet.stat().st_size == 0: + raise AssertionError("Missing native server recipe packet") + row["operations"].append({"operation": operation, "report": f"recipes-{name}.json", + "packet_sha256": digest(packet), **counts}) + server.send("save-all flush") + finally: + server.close() + current_recipes = {recipe["id"] for recipe in result["bookRecipes"]} + row["vanilla_recipe_pruning"] = audit_unlock_log(runtime / f"console-{label}.log", previous_recipes, current_recipes) + previous_recipes = current_recipes + verify_level(runtime, entry) + row["status"] = "PASS" + artifacts = [runtime / f"recipes-{mode}-sweep.{extension}" for mode in ("normal", "tech", "apocalyptic") + for extension in ("bin", "json")] + report["recipe_fixture"] = {"directory": str(runtime), "registry": verify_level(runtime, entry), + "files": [{"file": path.name, "sha256": digest(path)} for path in artifacts]} + + +def progression_campaign(root, target, entry, java, port, report): + runtime = root / "progression" + launcher = stage_native(runtime, target, port, "progression") + write_json(runtime / "progression-fixture.json", progression_fixture()) + pack = runtime / WORLD / "datapacks/progression-override" + write_json(pack / "pack.mcmeta", {"pack": {"pack_format": 4, "description": "Disposable loot-override witness"}}) + write_json(pack / "data/minecraft/loot_tables/chests/simple_dungeon.json", { + "pools": [{"name": "test_override", "rolls": 1, "entries": [{"type": "item", "name": "minecraft:dirt"}]}]}) + for mode in MODES: + for rubber in (True, False): + configure(runtime, mode, rubber) + for phase in ("initial", "restart"): + label = mode.lower() + ("-rubber" if rubber else "-no-rubber") + "-" + phase + print(f"Native115 progression boot {label}", flush=True) + server = Server(runtime, java, launcher, label) + try: + for reload in ((False, True) if phase == "initial" else (False,)): + name = label + ("-reload" if reload else "") + row = {"mode": mode, "rubber": rubber, "phase": "reload" if reload else phase, + "report": f"progression-{name}.json", "status": "RUNNING"} + report["progression_cases"].append(row) + write_json(root / "native115-recipes-results.json", report) + if reload: + server.send("reload") + server.wait("Reloading!", 60) + server.send(f"advprogression {name}") + server.wait(f"ADVPROGRESSION COMPLETE {name}", 240) + result = read_json(runtime / row["report"]) + validate_progression(result, mode, rubber) + row.update(status="PASS", targets=len(result["targets"]), + witnesses=len(result["processWitnesses"]), blocked=result["blocked"]) + server.send("save-all flush") + finally: + server.close() + require_clean_log(runtime / f"console-{label}.log") + verify_level(runtime, entry) + + +def run(args): + manifest = args.target_manifest.resolve() + target = native_target(manifest) + entry = read_json(manifest)["target115"] + root = output_path(args.output) + for protected in (manifest, target[0], *target[2]): + require_disjoint(root, protected) + java = args.java8.resolve() + if not java.is_file() or 'JAVA_VERSION="1.8.' not in (java.parent.parent / "release").read_text(): + raise ValueError("Supply an existing Java 8 bin/java.exe") + require_free_port(args.port) + definition, sources = recipe_fixture(target[2]) + before = fingerprint(target[0]) + root.mkdir(parents=True, exist_ok=False) + report = {"status": "RUNNING", "minecraft": "1.15.2", "forge": "31.2.57", "campaign": args.campaign, + "target_manifest": str(manifest), "target_manifest_sha256": digest(manifest), + "target_jars": entry["jars"], "java": str(java), "java_sha256": digest(java), + "unlock_boots": [], "progression_cases": [], "recipe_inventory": dict(MOD_COUNTS), + "scope": "packaged native server assertions and recipe exports; not graphical/manual acceptance"} + write_json(root / "packaged-recipe-sources.json", sources) + write_json(root / "seed-fingerprint-before.json", before) + try: + if args.campaign in ("all", "unlock"): + unlock_campaign(root, target, entry, java, args.port, definition, report) + if args.campaign in ("all", "progression"): + progression_campaign(root, target, entry, java, args.port, report) + if args.campaign in ("all", "unlock") and len(report["unlock_boots"]) != 5: + raise AssertionError("The native unlock campaign did not complete five boots") + if args.campaign in ("all", "progression") and len(report["progression_cases"]) != 18: + raise AssertionError("The native progression campaign did not complete eighteen checks") + native_target(manifest) + if digest(manifest) != report["target_manifest_sha256"]: + raise AssertionError("Pinned manifest changed during the campaign") + report["status"] = "PASS" + except BaseException as failure: + report.update(status="FAIL", error=f"{type(failure).__name__}: {failure}") + raise + finally: + after = fingerprint(target[0]) + write_json(root / "seed-fingerprint-after.json", after) + report["seed_unchanged"] = before == after + if before != after: + report.update(status="FAIL", seed_error="Protected installed server seed changed") + write_json(root / "native115-recipes-results.json", report) + if before != after: + raise AssertionError("Protected installed server seed changed") + print(f"Native115 recipe campaign PASS: {root}", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, default=RIG / "build" / ("native115-recipes-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S"))) + parser.add_argument("--port", type=int, default=25577) + parser.add_argument("--campaign", choices=("all", "unlock", "progression"), default="all") + run(parser.parse_args()) + + +if __name__ == "__main__": + main() diff --git a/test-rig/migration/native115_refresh.py b/test-rig/migration/native115_refresh.py new file mode 100644 index 0000000..81d19e7 --- /dev/null +++ b/test-rig/migration/native115_refresh.py @@ -0,0 +1,110 @@ +"""Rerun archived frozen/connected fixture inputs against a new pinned candidate. + +Previous results select immutable inputs only. Every destination is new and all +state/operating verdicts are recomputed by the existing native runners. +""" + +import argparse +from argparse import Namespace +from pathlib import Path +import socket +import traceback +from unittest.mock import patch + +import native115 +import native115_connected +from matrix import digest, write_json +from native115_lifecycle import read_json + + +def snapshot_name(source, expected): + matches = sorted(p.name for p in source.glob("migration-*.json") if digest(p) == expected) + if not matches: + raise AssertionError("No retained snapshot matches its archived digest") + # Frozen conversion/restart snapshots can be byte-for-byte identical. + # Identity remains the exact archived SHA, never a preferred passing state. + return "migration-restarted.json" if "migration-restarted.json" in matches else matches[0] + + +def run(args): + output = native115.output_path(args.output) + for protected in (args.previous, args.target_manifest): + native115.require_disjoint(output, protected) + native115.pinned_profile(args.target_manifest.resolve(), "target115") + previous = read_json(args.previous) + if previous.get("status") != "PASS": + raise AssertionError("Select a completed archived campaign as fixture provenance") + output.mkdir(parents=True, exist_ok=False) + report = dict(status="RUNNING", scope=args.kind, previous=str(args.previous.resolve()), + previousSha256=digest(args.previous), targetManifest=str(args.target_manifest.resolve()), + targetManifestSha256=digest(args.target_manifest), routes=[]) + rows = previous["routes"] if args.kind == "frozen" else previous["sources"] + for index, row in enumerate(rows): + outcome = dict(index=index, status="RUNNING") + report["routes"].append(outcome) + try: + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + if args.kind == "frozen": + old = Path(row["runtime"]) / (args.previous_target + "-provenance.json") + if digest(old) != row["provenanceSha256"]: + raise AssertionError("Archived fixture provenance changed") + provenance = read_json(old) + source = Path(provenance["source"]) + hashes = output / f"source-{index:02}-approved-hashes.json" + write_json(hashes, provenance["sourceHashes"]) + selected = snapshot_name(source, provenance["sourceSnapshotSha256"]) + settings = Namespace(source=source, source_manifest=Path(provenance["sourceManifest"]), + source_key=provenance["sourceKey"], source_hashes=hashes, + source_snapshot=selected, target_manifest=args.target_manifest, + java8=args.java8, output=output / f"route-{index:02}") + stage = native115.stage + def native_stage(runtime, profile): + launcher = stage(runtime, profile) + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() + if not line.startswith(("server-port=", "generator-settings="))] + properties.write_text("\n".join(lines) + f"\nserver-port={args.port}\ngenerator-settings=\n", encoding="utf-8") + return launcher + with patch.object(native115, "stage", native_stage): + code = native115.run(settings) + outcome.update(source=str(source), sourceSnapshot=selected, runtime=str(settings.output), + comparison=read_json(settings.output / "state-comparison.json")) + else: + old = Path(row["report"]) + if digest(old) != row["sha256"]: + raise AssertionError("Archived connected provenance changed") + provenance = read_json(old) + source = Path(provenance["source"]) + hashes = Path(provenance["sourceHashes"]) + if digest(hashes) != provenance["sourceHashesSha256"]: + raise AssertionError("Archived source fingerprint changed") + settings = Namespace(source=source, source_manifest=Path(provenance["sourceManifest"]), + source_key=provenance["sourceKey"], source_hashes=hashes, + source_checkpoint=provenance["sourceCheckpoint"], target_manifest=args.target_manifest, + java8=args.java8, output=output / f"route-{index:02}", + ticks=512, port=args.port) + code = native115_connected.run(settings) + outcome.update(source=str(source), runtime=str(settings.output), + result=str(settings.output / "native115-connected-results.json")) + outcome["status"] = "PASS" if code == 0 else "FAIL" + except Exception as error: + outcome.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + write_json(output / "native115-refresh-results.json", report) + print(f"{args.kind}/{index + 1}/{len(rows)}: {outcome['status']}", flush=True) + report["status"] = "PASS" if len(rows) == (13 if args.kind == "frozen" else 5) and all( + row["status"] == "PASS" for row in report["routes"]) else "FAIL" + write_json(output / "native115-refresh-results.json", report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("kind", choices=("frozen", "connected")) + parser.add_argument("--previous", type=Path, required=True) + parser.add_argument("--previous-target", choices=("native114", "native115"), default="native114") + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=25595) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native115_respawn.py b/test-rig/migration/native115_respawn.py new file mode 100644 index 0000000..01f7484 --- /dev/null +++ b/test-rig/migration/native115_respawn.py @@ -0,0 +1,143 @@ +"""Retained dimension/player fixtures through native 1.15 death and respawn.""" + +import argparse +import copy +import itertools +import json +import os +from pathlib import Path +import shutil +import socket +import tempfile + +import nbtlib +from actors import enchantments, semantic +from dimensions import PLAYER_STATE, exact_item_differences +from native115_actors import ROUTES, baseline +from matrix import WORLD, Server, copy_fixture, differences, digest, fingerprint, parse_nbt, write_json +from native115 import output_path, pinned_profile, require_clean_log, require_disjoint +from respawn import respawn_verdict, spawn_reference + + +def native_respawn_verdict(record, registry, world_spawn): + # Only translate the exact renamed platform class. Unknown handlers still fail. + value = copy.deepcopy(record) + if value.get("respawn", {}).get("handler") == "ServerPlayNetHandler.PERFORM_RESPAWN": + value["respawn"]["handler"] = "NetHandlerPlayServer.PERFORM_RESPAWN" + return respawn_verdict(value, registry, world_spawn) + + +def publish_definition(path, value): + # The tick probe reads this file concurrently; publish only complete JSON. + descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp") + os.close(descriptor) + temporary = Path(name) + try: + write_json(temporary, value) + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def run(args): + from actors import boot + root = output_path(args.output) + require_disjoint(root, args.fixtures) + target = pinned_profile(args.target_manifest.resolve(), "target115") + with socket.socket() as port: + port.bind(("127.0.0.1", args.port)) + root.mkdir(parents=True) + results = {} + sources = {} + for route in args.routes: + source = args.fixtures / route + before = fingerprint(source) + sources[route] = {"path": str(source.resolve()), "fingerprint": before} + write_json(root / "source-fingerprints.json", sources) + runtime = root / route + launcher = boot(runtime, target, args.java8, args.port) + copy_fixture(source, runtime) + shutil.copy2(source / "dimension-fixture.json", runtime / "dimension-fixture.json") + props = runtime / "server.properties" + props.write_text(props.read_text().replace("allow-nether=false", "allow-nether=true")) + definition = json.loads((runtime / "dimension-fixture.json").read_text()) + player_file = runtime / WORLD / "playerdata" / (definition["playerUuid"] + ".dat") + expected_player = baseline(source, "dimensions")[0]["file"] + expected_items = {key: expected_player[key] for key in ("Inventory", "EnderItems")} + migrated_player = None + try: + for boot_label in ("respawn", "respawn-restart"): + server = Server(runtime, args.java8, launcher, boot_label) + try: + if migrated_player is None: + # Let the real pre-datafix world hook migrate the copy once. + # Independent deaths restore that native player, not raw old bytes + # after the hook has already completed for this process. + migrated_player = player_file.read_bytes() + bootstrap = semantic(nbtlib.load(player_file).unpack(), PLAYER_STATE, enchantments(runtime)) + bootstrap_issues = differences(expected_items, bootstrap, "initial-migration") + bootstrap_issues.extend(exact_item_differences(expected_items, bootstrap, "initial-migration")) + if bootstrap_issues: + write_json(runtime / "bootstrap-failure.json", bootstrap_issues) + raise AssertionError("Native player bootstrap lost or changed source inventory") + folder = runtime / "bootstrap-evidence" + folder.mkdir() + shutil.copy2(player_file, folder / "player.dat") + shutil.copy2(runtime / WORLD / "level.dat", folder / "level.dat") + for keep, mode in itertools.product((True, False), ("forced", "bed", "missing")): + label = boot_label + "-" + ("keep" if keep else "drop") + "-" + mode + player_file.write_bytes(migrated_player) + definition["respawn"] = {"keepInventory": keep, "bedMode": mode} + publish_definition(runtime / "dimension-fixture.json", definition) + server.send("advdimensions respawn " + label) + server.wait("ADVDIMENSIONS COMPLETE " + label, 120) + server.send("save-all flush") + server.wait("Saved the", 45) + record = json.loads((runtime / f"dimensions-{label}.json").read_text()) + registry = enchantments(runtime) + verdict = native_respawn_verdict(record, registry, spawn_reference(runtime / WORLD / "level.dat")) + observed_before = semantic(parse_nbt(record["respawn"]["before"]), PLAYER_STATE, registry) + source_issues = differences(expected_items, observed_before, "source-inventory") + source_issues.extend(exact_item_differences(expected_items, observed_before, "source-inventory")) + verdict["differences"].extend(source_issues) + if source_issues: + verdict["status"] = "FAIL" + saved = semantic(nbtlib.load(player_file).unpack(), PLAYER_STATE, registry) + observed = semantic(parse_nbt(record["respawn"]["after"]), PLAYER_STATE, registry) + issues = differences(observed, saved, "saved-player") + exact_item_differences(observed, saved, "saved-player") + verdict["differences"].extend(issues) + if issues: + verdict["status"] = "FAIL" + folder = runtime / "respawn-evidence" / label + folder.mkdir(parents=True) + shutil.copy2(player_file, folder / "player.dat") + shutil.copy2(runtime / WORLD / "level.dat", folder / "level.dat") + results[route + "/" + label] = verdict + write_json(root / "results.json", results) + print(route + "/" + label + "=" + verdict["status"], flush=True) + finally: + server.close() + require_clean_log(runtime / ("console-" + boot_label + ".log")) + finally: + unchanged = fingerprint(source) == before + write_json(runtime / "source-integrity.json", {"unchanged": unchanged}) + if not unchanged: + raise AssertionError("Protected respawn source changed") + status = "FAIL" if any(result["status"] != "PASS" for result in results.values()) else "PASS" + write_json(root / "summary.json", {"status": status, "cases": len(results), "results": results, + "targetManifest": str(args.target_manifest.resolve()), "targetManifestSha256": digest(args.target_manifest), + "sourceFixtures": "UNCHANGED", "realClientLoginAndDeathScreen": "NOT_TESTED", + "oracleAdapter": "Exact ServerPlayNetHandler to NetHandlerPlayServer class rename only"}) + return int(status != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixtures", type=Path, required=True) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, choices=range(25584, 25588), required=True) + parser.add_argument("--routes", nargs="+", choices=ROUTES, + default=["original110", "maintained112", "original110-chained-113"]) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native115_ticket_networks.py b/test-rig/migration/native115_ticket_networks.py new file mode 100644 index 0000000..7abce79 --- /dev/null +++ b/test-rig/migration/native115_ticket_networks.py @@ -0,0 +1,175 @@ +"""Ticket-halo-aware native network layouts; historical oracles remain unchanged.""" + +from pathlib import Path +import json +import shutil +import time + +import extended_lifecycle as historical +from matrix import digest, write_json +from native115 import RIG, jar_identity, output_path, pinned_profile, require_disjoint +import native115_extended +from native115_partial import (NativeExtendedRun, NativePartialRun, campaign, + common_parser, exercise_bridge, exercise_partial, + selected_chunks) + + +def halo_chunks(fixture, group): + cores = fixture["groups"][group] + return [[x, z] for x in range(fixture["min"][0] >> 4, (fixture["max"][0] >> 4) + 1) + for z in range(fixture["min"][2] >> 4, (fixture["max"][2] >> 4) + 1) + if any(max(abs(x - a), abs(z - b)) <= 2 for a, b in cores)] + + +def linear_fixtures(): + result = [] + for index, name in enumerate(("C-STEAM", "C-ELECTRIC", "C-FLUID")): + base = 20480 + index * 512 + origin, sink = [base + 8, 80, 2056], [base + 136, 80, 2056] + pipes = [[x, 80, 2056] for x in range(base + 9, base + 136)] + fixture = dict(id=name, nativeTicketNetwork=True, min=[base, 72, 2048], max=[base + 159, 90, 2079], + witnesses=[origin, *pipes[:3], sink, *pipes[3:]], pipes=pipes, + groups=dict(supply=[[base >> 4, 128]], machine=[[(base >> 4) + 8, 128]])) + for group in ("supply", "machine"): + fixture["groups"][group + "halo"] = halo_chunks(fixture, group) + result.append(fixture) + return result + + +def bridge_fixtures(): + result = [] + for index, name in enumerate(("C-STEAM", "C-ELECTRIC", "C-FLUID")): + base = 24576 + index * 512 + origin, sink = [base + 13, 80, 2056], [base + 9, 80, 2056] + path = [[base + 13, 80, z] for z in range(2057, 2185)] + path += [[base + x, 80, 2184] for x in range(12, 8, -1)] + path += [[base + 9, 80, z] for z in range(2183, 2056, -1)] + fixture = dict(id=name, nativeTicketNetwork=True, min=[base, 72, 2048], max=[base + 31, 90, 2207], + witnesses=[origin, sink, *path], pipes=path, + groups=dict(bridge=[[base >> 4, 136]], endpoints=[[base >> 4, 128]])) + fixture["groups"]["bridgehalo"] = halo_chunks(fixture, "bridge") + result.append(fixture) + return result + + +def extended_fixtures(): + return [f for f in historical.fixtures() if "route" in f] + linear_fixtures() + + +def archive_harness(base_manifest, harness, output): + """Generate a new immutable input set, never editing its parent manifest.""" + output = output_path(output) + require_disjoint(output, base_manifest) + require_disjoint(output, harness) + pinned_profile(base_manifest.resolve(), "target115") + definition = json.loads(base_manifest.read_text()) + entry = next(j for j in definition["target115"]["jars"] if j["id"] == "advantageworkstest") + jar_identity(harness, entry["id"], entry["version"]) + checksum = digest(harness) + archive = RIG / "build/native115-artifacts" / checksum / harness.name + archive.parent.mkdir(parents=True, exist_ok=True) + if archive.exists(): + if digest(archive) != checksum: + raise AssertionError("Immutable harness archive changed") + else: + shutil.copy2(harness, archive) + if digest(archive) != checksum or digest(harness) != checksum: + raise AssertionError("Harness changed during archive staging") + entry.update(path=str(archive.resolve()), sha256=checksum, originPath=str(harness.resolve())) + definition["target115"].update(sourceManifest=str(base_manifest.resolve()), sourceManifestSha256=digest(base_manifest)) + write_json(output, definition) + pinned_profile(output, "target115") + return output + + +def fill_commands(definition, command): + parts = command.split() + if len(parts) != 9 or parts[0] != "fill" or parts[7:] != ["minecraft:air", "replace"]: + return None + minimum, maximum = list(map(int, parts[1:4])), list(map(int, parts[4:7])) + f = next((f for f in definition if f.get("nativeTicketNetwork") and f["min"] == minimum and f["max"] == maximum), None) + if f is None: + return None + result = [] + for x in range(minimum[0], maximum[0] + 1, 16): + for z in range(minimum[2], maximum[2] + 1, 16): + low = [x, minimum[1], z] + high = [min(x + 15, maximum[0]), maximum[1], min(z + 15, maximum[2])] + result.append("fill " + " ".join(map(str, low + high)) + " minecraft:air replace") + return result + + +class NativeTicketNetworkRun(NativePartialRun): + def command(self, command): + pieces = fill_commands(self.definition, command) + for piece in pieces if pieces is not None else [command]: + super().command(piece) + + def probe(self, operation, label): + if operation.startswith("release-"): + group = operation[8:] + if selected_chunks(self.definition, group + "halo"): + before = self._raw_probe("snapshot", label + "-native-before") + self._raw_probe("release-" + group + "halo", label + "-native-halo") + result = self._raw_probe(operation, label) + self._released_groups[group] = dict(before=before, selected=selected_chunks(self.definition, group), + deadline=time.monotonic() + 45) + return result + if operation.startswith("load-") and selected_chunks(self.definition, operation[5:] + "halo"): + self._raw_probe(operation + "halo", label + "-native-halo") + return super().probe(operation, label) + + +class NativeTicketExtendedRun(NativeTicketNetworkRun, NativeExtendedRun): + def build(self): + self.probe("load", "loaded") + for f in self.definition: + self.command("fill " + " ".join(map(str, f["min"] + f["max"])) + " minecraft:air replace") + if "route" in f: + self.block(f["controls"][0], "minecraft:redstone_block") + self.block(f["from"], f["block"] + "[facing=" + f["facing"] + "]") + for pos in f["route"][1:] + f.get("alternatives", []): + self.block(pos, f["block"].replace("_drill", "_track")) + for pos in f["route"]: + self.block(historical.offset(pos, f["direction"]), "minecraft:bedrock") + self.inventory(f["from"], 0, 'minecraft:dirt{AdvWorksWitness:"' + f["id"] + '"}', 7) + self.inventory(f["from"], 1, "minecraft:apple", 13) + continue + fluid, steam = f["id"] == "C-FLUID", f["id"] == "C-STEAM" + pipe = "poweradvantage:fluid_pipe" if fluid else "steamadvantage:steam_pipe" if steam else "electricadvantage:electric_conduit" + self.block(f["witnesses"][0], pipe if fluid else "poweradvantage:infinite_" + ("steam" if steam else "electricity")) + for pos in f["pipes"]: + self.block(pos, pipe) + self.block(f["witnesses"][4], "poweradvantage:fluid_metal_tank" if fluid else + "steamadvantage:steam_furnace" if steam else "electricadvantage:electric_furnace") + if not fluid: + if steam: + self.inventory(f["witnesses"][4], 0, "minecraft:coal", 64) + self.inventory(f["witnesses"][4], 1 if steam else 0, "minecraft:iron_ore", 4) + self.probe("offer", "commission-fluid") + self.probe("watch", "watching") + + +def main(): + parser = common_parser(__doc__) + parser.add_argument("--campaign", choices=("partial", "bridge", "extended"), required=True) + args = parser.parse_args() + definition = {"partial": linear_fixtures, "bridge": bridge_fixtures, "extended": extended_fixtures}[args.campaign]() + operation = {"partial": exercise_partial, "bridge": exercise_bridge, "extended": native115_extended.exercise}[args.campaign] + runner_type = NativeTicketNetworkRun if args.campaign == "bridge" else NativeTicketExtendedRun + own_path, own_hash = Path(__file__), digest(Path(__file__)) + def exercise(runner, progress): + archive = runner.runtime / "native115-ticket-network-protocol.py" + shutil.copy2(own_path, archive) + result = operation(runner, progress) + result["native_ticket_protocol_unchanged"] = digest(own_path) == own_hash == digest(archive) + result["native_ticket_protocol_sha256"] = own_hash + if not result["native_ticket_protocol_unchanged"]: + result["status"] = "FAIL" + progress(result) + return result + return campaign(args, "halo-" + args.campaign, definition, exercise, runner_type) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native115_transformer_order.py b/test-rig/migration/native115_transformer_order.py new file mode 100644 index 0000000..50c0225 --- /dev/null +++ b/test-rig/migration/native115_transformer_order.py @@ -0,0 +1,195 @@ +"""Observe and reverse real native Forge 31 Base/Power migration transformers.""" + +import argparse +import json +from pathlib import Path +import shutil +import socket +import subprocess +import time +import types + +import actors +import transformer_order as historical +from matrix import WORLD, Server, compare, copy_fixture, digest, fingerprint, write_json +from native115 import LEGACY_POWER, native_normalized, output_path, pinned_profile, require_clean_log, require_disjoint + + +COREMODS = Path("libraries/net/minecraftforge/coremods/2.0.3/coremods-2.0.3.jar") +BRIDGE_LIBRARIES = ( + "org/ow2/asm/asm/7.2/asm-7.2.jar", "org/ow2/asm/asm-tree/7.2/asm-tree-7.2.jar", + "cpw/mods/modlauncher/5.1.2/modlauncher-5.1.2.jar", +) +SUFFIXES = { + "world_info": ("net/minecraft/world/storage/SaveFormat", ["prepareLegacyWorld"]), + "chunk_status": ("net/minecraft/world/chunk/storage/ChunkLoader", ["prepareLegacyChunk", "finalizeLegacyChunk"]), + "worldgen_guard": historical.SUFFIXES["worldgen_guard"], +} +# Execute the historical evidence oracle unchanged, binding only target class names. +native_order_evidence = types.FunctionType(historical.order_evidence.__code__, + dict(historical.order_evidence.__globals__, SUFFIXES=SUFFIXES), "native_order_evidence", + historical.order_evidence.__defaults__) + + +def build_bridge(root, target, java): + seed = target[0] + libraries = seed / "libraries" + dependencies = [libraries / path for path in BRIDGE_LIBRARIES] + dependencies.append(next((libraries / "com/google/code/gson/gson").rglob("gson-*.jar"))) + classes = root / "probe-classes" + classes.mkdir() + source = Path(__file__).parent / "transformer-order-probe/TransformerOrderBridge.java" + cp = ";".join(str(path) for path in dependencies) + commands = [[str(java.with_name("javac.exe")), "-source", "8", "-target", "8", "-encoding", "UTF-8", + "-cp", cp, "-d", str(classes), str(source)], + [str(java), "-cp", str(classes) + ";" + cp, "qualification.order.TransformerOrderBridge", + str(seed / COREMODS), str(root / "coremods-order-test.jar"), str(classes)]] + for command in commands: + result = subprocess.run(command, capture_output=True, text=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + proof = historical.validate_patch(seed / COREMODS, root / "coremods-order-test.jar") + proof.update({"sourceSha256": digest(source), "commands": commands, + "dependencies": {str(path): digest(path) for path in dependencies}}) + write_json(root / "probe-build.json", proof) + return root / "coremods-order-test.jar" + + +def checkpoint(runtime, java, launcher, label, first): + server = Server(runtime, java, launcher, label) + try: + server.snapshot(label) + actors.actor_command(server, True, "snapshot" if first else "read", label) + frozen = actors.freeze_result(json.loads((runtime / f"actors-{label}.json").read_text())) + state = actors.capture(runtime, label) + historical.retain_player_evidence(runtime, label) + if first: + actors.actor_command(server, True, "read", label + "-adapter") + adapter = actors.capture(runtime, label + "-adapter") + adapter_freeze = actors.freeze_result(json.loads((runtime / f"actors-{label}-adapter.json").read_text())) + historical.retain_player_evidence(runtime, label + "-adapter") + else: + adapter, adapter_freeze = state, frozen + adapter.pop("embedded", None) + if not first: + state.pop("embedded", None) + if frozen["status"] != "PASS" or adapter_freeze["status"] != "PASS": + raise AssertionError("Transformer controls require genuinely frozen actors") + remote = 1024 if first else 2048 + server.send(f"forceload add {remote} 1024") + server.send("say NATIVE115-ORDER-GUARD-SCHEDULED") + server.wait("NATIVE115-ORDER-GUARD-SCHEDULED", 30) + event_file = runtime / "transformer-order-events.jsonl" + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + if event_file.exists() and event_file.read_text().count('"class":"net/minecraft/world/gen/WorldGenRegion"') == 2: + break + time.sleep(0.2) + else: + raise AssertionError("Native remote generation did not apply both real guard transformers") + server.send(f"forceload remove {remote} 1024") + server.send("save-all flush") + server.wait("Saved the", 45) + finally: + server.close() + require_clean_log(runtime / f"console-{label}.log") + proof = native_order_evidence(runtime, (runtime / "transformer-order.mode").read_text().strip()) + for old, new in (("transformer-order-events.jsonl", f"transformer-order-events-{label}.jsonl"), + ("transformer-order-bytecode", f"transformer-order-bytecode-{label}")): + shutil.move(str(runtime / old), str(runtime / new)) + write_json(runtime / f"order-proof-{label}.json", proof) + write_json(runtime / f"semantic-{label}.json", state) + write_json(runtime / f"semantic-{label}-adapter.json", adapter) + return state, adapter, {"status": "PASS", "order": proof, "freeze": frozen, "adapterFreeze": adapter_freeze} + + +def run(args): + root = output_path(args.output) + require_disjoint(root, args.fixtures) + target = pinned_profile(args.target_manifest.resolve(), "target115") + if not args.probe_only: + with socket.socket() as port: + port.bind(("127.0.0.1", args.port)) + root.mkdir(parents=True) + patched = build_bridge(root, target, args.java8) + if args.probe_only: + return 0 + results, cross, fingerprints = {}, {}, {} + for name in args.sources: + source = args.fixtures / name + before = fingerprint(source) + if fingerprint(source / WORLD) != json.loads((source / "fixture-hashes.json").read_text()): + raise AssertionError("Protected original actor fixture changed") + fingerprints[name] = {"source": str(source.resolve()), "fingerprint": before} + write_json(root / "source-fingerprints.json", fingerprints) + try: + mixed = root / (name + "-mixed-input") + mixed.mkdir() + expected = historical.prepare_fixture(source, mixed) + mixed_hashes = fingerprint(mixed) + blocks = LEGACY_POWER / "run-migration-matrix" / ( + "original-cyano-20261005-r1/original110" if name == "original110" else "full-20261005-r1/maintained112") + modes = {} + for mode in args.orders: + runtime = root / (name + "-" + mode) + launcher = actors.boot(runtime, target, args.java8, args.port) + shutil.copy2(patched, runtime / COREMODS) + (runtime / "transformer-order.mode").write_text(mode) + copy_fixture(mixed, runtime) + shutil.copy2(mixed / "actor-fixture.json", runtime / "actor-fixture.json") + converted, adapter, first = checkpoint(runtime, args.java8, launcher, "converted", True) + backups = historical.migration_backups(runtime) + provenance = historical.backup_provenance(mixed, runtime) + restarted, _, second = checkpoint(runtime, args.java8, launcher, "restarted", False) + checks = { + "conversion": actors.compare({k: v for k, v in expected.items() if k != "adapter"}, converted), + "adapter": actors.compare({k: v for k, v in expected.items() if k != "embedded"}, adapter), + "restart": actors.compare({k: v for k, v in expected.items() if k != "embedded"}, restarted), + "idempotence": historical.strict_equal(adapter, restarted), + "backup-stability": historical.strict_equal(backups, historical.migration_backups(runtime)), + "backup-provenance": provenance, "converted-controls": first, "restart-controls": second, + } + for label in ("converted", "restarted"): + entries = json.loads((runtime / f"migration-{label}.json").read_text()) + stock = runtime / f"migration-stock-{label}.json" + write_json(stock, entries[:-len(historical.METAL_BLOCKS)]) + checks["blocks-" + label] = compare(blocks / "migration-source.json", stock, native_normalized) + checks["metals-" + label] = historical.strict_equal(list(historical.METAL_BLOCKS), + [entry["actualName"] for entry in entries[-len(historical.METAL_BLOCKS):]]) + historical.assert_staged(runtime) + modes[mode] = {"actors": adapter, "actorsRestart": restarted, + "blocks": historical.block_semantics(runtime, "converted"), + "blocksRestart": historical.block_semantics(runtime, "restarted")} + results[name + "/" + mode] = checks + write_json(runtime / "order-results.json", checks) + write_json(root / "results.json", results) + print(name + "/" + mode + ": " + ", ".join(key + "=" + value["status"] for key, value in checks.items()), flush=True) + cross[name] = {mode: historical.strict_equal(modes[args.orders[0]], value) for mode, value in modes.items()} + if fingerprint(mixed) != mixed_hashes: + raise AssertionError("Disposable mixed input was altered by destination runs") + finally: + if fingerprint(source) != before: + raise AssertionError("Protected transformer source changed") + failed = any(value["status"] != "PASS" for checks in results.values() for value in checks.values()) + failed |= any(value["status"] != "PASS" for modes in cross.values() for value in modes.values()) + dependent = any(not checks["backup-provenance"]["power_backup_matches_original"] for checks in results.values()) + status = "FAIL" if failed else "PASS_WITH_BACKUP_ORDER_DEPENDENCE" if dependent else "PASS" + write_json(root / "summary.json", {"status": status, "semanticStatus": "FAIL" if failed else "PASS", "crossOrder": cross, + "runtimes": len(results), "serverLaunches": len(results) * 2, "sourceFixtures": "UNCHANGED", + "targetManifestSha256": digest(args.target_manifest), "patchedLibrarySha256": digest(patched), + "powerBackupOriginOrderDependent": dependent}) + return int(failed) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixtures", type=Path, required=True) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, choices=range(25584, 25588), required=True) + parser.add_argument("--sources", nargs="+", choices=("original110", "maintained112"), default=["original110", "maintained112"]) + parser.add_argument("--orders", nargs="+", choices=historical.ORDERS, default=list(historical.ORDERS)) + parser.add_argument("--probe-only", action="store_true") + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/test_audit_native115_partial.py b/test-rig/migration/test_audit_native115_partial.py new file mode 100644 index 0000000..a9cc669 --- /dev/null +++ b/test-rig/migration/test_audit_native115_partial.py @@ -0,0 +1,65 @@ +"""Independent native audit must fail closed on missing or altered engine proof.""" + +import copy +import json +from pathlib import Path +import tempfile +import unittest + +from audit_native115_partial import geometry_evidence, snapshot + + +GEOMETRY = dict(fullLevel=33, entityTickingLevel=31, maxDistance=11, + lastHolderLevel=44, holderRadius=13, observedForcedLevels=[31]) + + +class NativeGeometryAuditTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.runtime = Path(self.temp.name) + self.results = self.runtime / "advantage-works-results" + self.results.mkdir() + + def observation(self, label, geometry): + path = self.results / ("lifecycle-" + label + ".json") + path.write_text(json.dumps(dict(worldTime=42, ticketGeometry=geometry)), encoding="utf-8") + return path + + def test_raw_geometry_is_checked_without_mutating_evidence(self): + path = self.observation("valid", GEOMETRY) + original = path.read_bytes() + self.assertEqual(snapshot(self.runtime, "valid")["ticketGeometry"], GEOMETRY) + result = geometry_evidence(self.runtime) + self.assertEqual(result["snapshots"], 1) + self.assertEqual(result["forced_ticket_snapshots"], 1) + self.assertEqual(path.read_bytes(), original) + + def test_an_altered_raw_observation_cannot_hide_behind_valid_canonical(self): + self.observation("canonical", GEOMETRY) + altered = copy.deepcopy(GEOMETRY) + altered["lastHolderLevel"] = 43 + self.observation("pending", altered) + with self.assertRaisesRegex(AssertionError, "geometry differs"): + geometry_evidence(self.runtime) + + def test_no_observed_forced_ticket_is_not_runtime_geometry_proof(self): + missing = copy.deepcopy(GEOMETRY) + missing["observedForcedLevels"] = [] + self.observation("unforced", missing) + with self.assertRaisesRegex(AssertionError, "No observed live"): + geometry_evidence(self.runtime) + + def test_missing_geometry_and_wrong_forced_level_are_rejected(self): + for value in ({}, dict(GEOMETRY, observedForcedLevels=[30])): + self.observation("invalid", value) + with self.assertRaises(AssertionError): + snapshot(self.runtime, "invalid") + + def test_empty_evidence_is_rejected(self): + with self.assertRaisesRegex(AssertionError, "No raw native"): + geometry_evidence(self.runtime) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115.py b/test-rig/migration/test_native115.py new file mode 100644 index 0000000..4fddfe3 --- /dev/null +++ b/test-rig/migration/test_native115.py @@ -0,0 +1,93 @@ +"""Destination-only safety checks; no Minecraft process or source world is opened.""" + +import json +from pathlib import Path +import tempfile +import unittest +import zipfile + +from native115 import LEGACY_POWER, RIG, jar_identity, native_normalized, output_path, pinned_profile, require_clean_log, require_disjoint +from matrix import differences, normalized + + +class Native115DestinationTest(unittest.TestCase): + def setUp(self): + root = RIG / "build/port-evidence" + root.mkdir(parents=True, exist_ok=True) + self.temp = tempfile.TemporaryDirectory(dir=root) + self.root = Path(self.temp.name) + + def tearDown(self): + self.temp.cleanup() + + def test_legacy_source_root_is_explicit_pa113(self): + self.assertEqual(LEGACY_POWER.name, "PowerAdvantage") + self.assertEqual(LEGACY_POWER.parent.name, "PA113") + + def test_output_must_be_new_and_inside_native_build_scope(self): + self.assertEqual(output_path(self.root / "fresh"), self.root / "fresh") + with self.assertRaises(FileExistsError): + output_path(self.root) + with self.assertRaises(ValueError): + output_path(RIG.parent / "world") + + def test_neither_ancestor_nor_descendant_of_source_is_writable(self): + source = self.root / "source" + for output in (source, source / "nested", self.root): + with self.assertRaises(ValueError): + require_disjoint(output, source) + require_disjoint(self.root / "destination", source) + + def test_native_identity_and_version_are_both_required(self): + jar = self.root / "example.jar" + with zipfile.ZipFile(jar, "w") as archive: + archive.writestr("META-INF/mods.toml", '[[mods]]\nmodId="poweradvantage"\nversion="2.4.6.115021"\n') + jar_identity(jar, "poweradvantage", "2.4.6.115021") + for identity, version in (("steamadvantage", "2.4.6.115021"), ("poweradvantage", "2.4.4.113021")): + with self.assertRaises(ValueError): + jar_identity(jar, identity, version) + + def test_pinned_source_hash_failure_is_not_ignored(self): + artifact = self.root / "input.jar" + artifact.write_bytes(b"not-the-pinned-artifact") + manifest = self.root / "source-profiles.json" + manifest.write_text(json.dumps({"target113": {"seed": ".", "launcher": "forge.jar", + "jars": [{"path": "input.jar", "sha256": "0" * 64}]}})) + with self.assertRaises(AssertionError): + pinned_profile(manifest, "target113") + + def test_native_manifest_rejects_historical_runtime_even_with_no_jars(self): + manifest = self.root / "target.json" + manifest.write_text(json.dumps({"target115": {"seed": ".", "launcher": "forge-1.13.2-25.0.223.jar", "jars": []}})) + with self.assertRaises(AssertionError): + pinned_profile(manifest, "target115") + + def test_native_omitted_empty_chest_lock_is_semantically_unlocked(self): + old = {"id": "Chest", "Lock": "", "Items": []} + new = {"id": "minecraft:chest", "Items": []} + self.assertEqual(native_normalized(old), native_normalized(new)) + self.assertNotIn("Lock", normalized(new)) # Historical default is unchanged. + + def test_missing_or_changed_real_chest_locks_remain_failures(self): + unlocked = native_normalized({"id": "minecraft:chest"}) + locked = native_normalized({"id": "minecraft:chest", "Lock": "secret"}) + self.assertTrue(differences(unlocked, locked)) + self.assertTrue(differences(locked, unlocked)) + self.assertTrue(differences(locked, native_normalized({"id": "minecraft:chest", "Lock": "different"}))) + + def test_custom_machine_empty_key_is_not_dropped_or_defaulted(self): + old = native_normalized({"id": "poweradvantage:machine", "Lock": ""}) + new = native_normalized({"id": "poweradvantage:machine"}) + self.assertTrue(differences(old, new)) + + def test_startup_errors_cannot_hide_behind_a_successful_snapshot(self): + log = self.root / "console.log" + log.write_text("[Server thread/INFO] Done\n[Server thread/ERROR] Failed to reload data packs\n") + with self.assertRaises(AssertionError): + require_clean_log(log) + log.write_text("[Server thread/WARN] No data fixer registered for custom entity\n[Server thread/INFO] Done\n") + require_clean_log(log) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_actors.py b/test-rig/migration/test_native115_actors.py new file mode 100644 index 0000000..2936d85 --- /dev/null +++ b/test-rig/migration/test_native115_actors.py @@ -0,0 +1,153 @@ +import unittest +from pathlib import Path +import tempfile +from unittest.mock import Mock, patch +import json + +import dimensions +from native115_actors import baseline, expected_state, native_dimension_state, release +from native115_audit_actors import inherited_sources, persistence_controls, replay + + +class NativeActorOracleTest(unittest.TestCase): + def test_frozen_comparison_retains_positions_and_nested_items(self): + old = {"file": {"Inventory": [{"Count": 17, "tag": {"nested": {"keep": 3}}}]}, + "entities": {"cart": {"Pos": [1, 2, 3], "Items": [{"Count": 4}]}}} + result = expected_state(old, "actors") + self.assertEqual(old, result) + result["entities"]["cart"]["Items"][0]["Count"] = 0 + self.assertEqual(4, old["entities"]["cart"]["Items"][0]["Count"]) + + def test_survival_only_removes_historical_movement_keys(self): + old = {"file": {}, "entities": {"wolf": {"Pos": [1, 2, 3], "Motion": [0, 0, 0], + "Rotation": [0, 0], "OwnerUUID": "owner", "ForgeData": {"keep": 8}}}} + self.assertEqual({"OwnerUUID": "owner", "ForgeData": {"keep": 8}}, + expected_state(old, "actors", survival=True)["entities"]["wolf"]) + + def test_dimension_oracle_keeps_live_relationships_and_quantities(self): + old = {"file": {"Inventory": [{"id": "steamadvantage:musket", "Count": 3}]}, + "entities": {"horse": {"Pos": [1, 2, 3], "Health": 20}}, + "relationships": {"horse": {"passengerUuids": ["wolf"]}}, "adapter": {"Dimension": -1}} + result = expected_state(old, "dimensions", survival=True) + self.assertEqual(old["relationships"], result["relationships"]) + self.assertEqual(old["file"], result["file"]) + + def test_native_leash_byte_is_derived_only_from_actual_saved_compound(self): + state = {"entities": {"horse": {"id": "minecraft:horse", "Leash": {"X": 1, "Y": 2, "Z": 3}}, + "wolf": {"id": "minecraft:wolf"}}, "relationships": {"horse": {"leashHolderType": "minecraft:leash_knot"}}} + result = native_dimension_state(state) + self.assertEqual(1, result["entities"]["horse"]["Leashed"]) + self.assertEqual(0, result["entities"]["wolf"]["Leashed"]) + self.assertNotIn("Leashed", state["entities"]["horse"]) + del state["entities"]["horse"]["Leash"] + self.assertEqual(0, native_dimension_state(state)["entities"]["horse"]["Leashed"]) + + def test_native_adapter_does_not_hide_vanilla_wolf_health_changes(self): + old = {"entities": {"wolf": {"id": "minecraft:wolf", "Health": 8.0}}, + "relationships": {}} + actual = {"entities": {"wolf": {"id": "minecraft:wolf", "Health": 20.0}}, + "relationships": {}} + expected = expected_state(old, "dimensions", survival=True) + result = dimensions.verdict(expected, native_dimension_state(actual)) + self.assertEqual("FAIL", result["status"]) + self.assertIn({"path": ".entities.wolf.Health", "expected": 8.0, "actual": 20.0}, + result["differences"]) + self.assertEqual(8.0, old["entities"]["wolf"]["Health"]) + + def test_dimension_release_never_saves_before_native_initialization(self): + server = Mock() + release(server, Path("unused"), "dimensions", "released") + server.send.assert_called_once_with("advdimensions operate released") + server.wait.assert_called_once_with("ADVDIMENSIONS COMPLETE released", 120) + + def test_actor_release_requests_real_tickets_not_synthetic_ticks(self): + server = Mock() + with patch("native115_actors.command", return_value="captured") as command: + self.assertEqual("captured", release(server, Path("unused"), "actors", "released")) + self.assertEqual("forceload add 96 -32 223 47", server.send.call_args_list[0].args[0]) + command.assert_called_once_with(server, Path("unused"), "actors", "operate", "released") + + def test_retained_baseline_selection_includes_player_layout_restart(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + for selected in ("source", "restarted", "runtime-restart"): + (root / ("semantic-" + selected + ".json")).write_text("{}") + with patch("native115_actors.actors.capture", return_value={"retain": True}) as capture: + self.assertEqual(({"retain": True}, selected), baseline(root, "actors")) + capture.assert_called_once_with(root, selected) + + def test_inherited_proof_reuses_old_fingerprint_and_rejects_new_data(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + proof = root / "old-proof.json" + source = root / "original-source" + expected = {"level.dat": "old-approved-hash"} + proof.write_text(json.dumps({"source": str(source), "sourceFingerprint": expected})) + with patch("native115_audit_actors.fingerprint", return_value=expected): + result = inherited_sources([proof]) + self.assertEqual(expected, result["receipts"][0]["sources"][0]["fingerprint"]) + with patch("native115_audit_actors.fingerprint", return_value={"level.dat": "fresh-but-not-approved"}): + with self.assertRaisesRegex(AssertionError, "inherited proof"): + inherited_sources([proof]) + + def test_aborted_route_reports_failure_without_reconstructing_unsaved_state(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + runtime = root / "broken" + runtime.mkdir() + source = root / "protected-source" + manifest = root / "archived-inputs.json" + manifest.write_text("{}") + error = {"status": "FAIL", "error": "Unclean shutdown"} + (root / "results.json").write_text(json.dumps({"broken": {"campaign-error": error}})) + (runtime / "native115-source-provenance.json").write_text(json.dumps({ + "source": str(source), "sourceFingerprint": {"approved": "hash"}, + "targetManifest": str(manifest), "targetManifestSha256": "manifest-hash"})) + (runtime / "input-jars.json").write_text("[]") + (runtime / "console-conversion.log").write_text("[Server thread/ERROR] Exception stopping the server") + with patch("native115_audit_actors.fingerprint", return_value={"approved": "hash"}), \ + patch("native115_audit_actors.digest", return_value="manifest-hash"), \ + patch("native115_audit_actors.pinned_profile"), \ + patch("native115_audit_actors.dimensions.capture") as capture: + result = replay(root, "dimensions") + capture.assert_not_called() + self.assertEqual("FAIL", result["status"]) + self.assertEqual(error, result["routes"]["broken"]["recordedCampaignError"]) + self.assertEqual(2, len(result["findings"])) + + def test_persistence_controls_require_every_bounded_save_receipt(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + for kind, counts in (("actors", (2, 3, 2)), ("dimensions", (2, 2, 1))): + for label, count in zip(("conversion", "restart", "runtime-restart"), counts): + (root / ("console-" + label + ".log")).write_text( + "Done (1.0s)!\n" + "Saved the game\n" * count + "Stopping server\n") + result = persistence_controls(root, kind) + self.assertEqual("PASS", result["status"]) + self.assertEqual(45, result["liveRunnerBudgetsSeconds"]["saveReceipt"]) + self.assertIn("not inferred", result["nativeHookListInspection"]) + (root / "console-conversion.log").write_text("Done (1.0s)!\nSaved the game\nStopping server\n") + self.assertEqual("FAIL", persistence_controls(root, "dimensions")["status"]) + + def test_persistence_controls_reject_load_save_npe_even_with_receipts(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + for label, count in zip(("conversion", "restart", "runtime-restart"), (2, 2, 1)): + (root / ("console-" + label + ".log")).write_text( + "Done (1.0s)!\n" + "Saved the game\n" * count + "Stopping server\n") + with (root / "console-conversion.log").open("a") as stream: + stream.write("WorldPersistenceHooks.lambda$handleWorldDataLoad$1\nNullPointerException\n" + "WorldPersistenceHooks.lambda$handleWorldDataSave$0\n") + result = persistence_controls(root, "dimensions") + self.assertEqual("FAIL", result["status"]) + self.assertEqual(3, len(result["boots"]["conversion"]["errors"])) + + def test_persistence_controls_reject_missing_boots(self): + with tempfile.TemporaryDirectory() as temp: + result = persistence_controls(Path(temp), "dimensions") + self.assertEqual("FAIL", result["status"]) + self.assertEqual(3, len(result["boots"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_broad.py b/test-rig/migration/test_native115_broad.py new file mode 100644 index 0000000..d95e72c --- /dev/null +++ b/test-rig/migration/test_native115_broad.py @@ -0,0 +1,185 @@ +"""Negative controls for the repaired broad drill oracle, not Minecraft passes.""" + +from copy import deepcopy +import json +from pathlib import Path +import tempfile +import unittest + +import nbtlib +import native115_broad as broad + + +class BroadOracleTests(unittest.TestCase): + def setUp(self): + definition = broad.read_json(broad.RIG / "src/main/resources/assets/advantageworkstest/works/works-1.13.2.json") + self.stations = [s for s in definition["stations"] if s["id"] in broad.DRILL_BAYS] + + def snapshot(self, station, moved=False): + rows = [] + for case in broad.drill_cases(station): + position = case["rear"] if moved else case["origin"] + marker = json.dumps(case["id"]) + data = ('{id:"' + case["block"] + '",x:' + str(position[0]) + ',y:' + str(position[1]) + + ',z:' + str(position[2]) + ',ForgeData:{AdvBayWitness:' + marker + '},Energy:[I;0],' + + 'Items:[{Slot:0b,id:"minecraft:diamond",Count:7b,tag:{AdvBayStock:' + marker + '}}]}') + row = dict(pos=list(position), block=case["block"], state='facing=' + case["facing"], nbt=data) + rows.append(row) + if not moved: + rows.append(dict(pos=list(case["rear"]), block="steamadvantage:steam_track" + if station["id"] == "S-05" else "electricadvantage:electric_track", state="track")) + if moved: + rows.append(dict(pos=list(case["origin"]), block="steamadvantage:steam_pipe" + if station["id"] == "S-05" else "electricadvantage:electric_conduit", state="pipe")) + return dict(snapshot=rows) + + def states(self, moved=False): + states = {} + for station in self.stations: + states.update(broad.drill_state(station, self.snapshot(station, moved), initial=not moved)) + return states + + def test_all_twelve_initial_then_moved_then_restarted(self): + initial, moved = self.states(), self.states(True) + self.assertEqual(12, broad.operating_state(initial, moved)["moved"]) + self.assertTrue(broad.restarted_state(moved, deepcopy(moved))["exactEnergy"]) + + def test_movement_cannot_replace_initial_placement_test(self): + for station in self.stations: + with self.assertRaisesRegex(AssertionError, "initial placement"): + broad.drill_state(station, self.snapshot(station, True), initial=True) + + def test_no_movement_cannot_clear_the_original_failure(self): + with self.assertRaisesRegex(AssertionError, "actual track movement"): + broad.operating_state(self.states(), self.states()) + + def test_initial_track_is_required(self): + station = self.stations[0] + doc = self.snapshot(station) + doc["snapshot"][1]["block"] = "minecraft:air" + with self.assertRaisesRegex(AssertionError, "initial declared rear track"): + broad.drill_state(station, doc, initial=True) + + def test_live_steam_bit_may_replace_the_conduit_left_in_the_old_cell(self): + station = self.stations[0] + doc = self.snapshot(station, True) + doc["snapshot"][1]["block"] = "steamadvantage:drillbit" + self.assertTrue(broad.drill_state(station, doc)["S-05:down-facing"]["moved"]) + + def test_missing_duplicate_wrong_facing_and_off_track_fail(self): + station = self.stations[0] + for mutation in ("missing", "duplicate", "facing", "off-track"): + with self.subTest(mutation=mutation): + doc = self.snapshot(station) + if mutation == "missing": + doc["snapshot"].pop(0) + elif mutation == "duplicate": + clone = deepcopy(doc["snapshot"][0]) + clone["pos"] = list(broad.drill_cases(station)[0]["rear"]) + doc["snapshot"].append(clone) + elif mutation == "facing": + doc["snapshot"][0]["state"] = "facing=west" + else: + doc["snapshot"][0]["pos"][0] += 1 + with self.assertRaises(AssertionError): + broad.drill_state(station, doc) + + def test_witness_and_inventory_and_coordinate_changes_fail(self): + station = self.stations[0] + for mutation in ("marker", "lost-stock", "duplicated-stock", "coordinates", "energy"): + with self.subTest(mutation=mutation): + doc = self.snapshot(station) + data = nbtlib.parse_nbt(doc["snapshot"][0]["nbt"]) + if mutation == "marker": + data["ForgeData"]["AdvBayWitness"] = nbtlib.String("replacement") + elif mutation == "lost-stock": + data["Items"][0]["Count"] = nbtlib.Byte(6) + elif mutation == "duplicated-stock": + data["Items"][0]["Count"] = nbtlib.Byte(8) + elif mutation == "coordinates": + data["x"] = nbtlib.Int(999) + else: + data["Energy"] = nbtlib.IntArray([2139095040]) + doc["snapshot"][0]["nbt"] = data.snbt() + with self.assertRaises(AssertionError): + broad.drill_state(station, doc) + + def test_stock_can_export_to_rear_chest_without_loss(self): + station = self.stations[0] + doc = self.snapshot(station, True) + row = doc["snapshot"][0] + data = nbtlib.parse_nbt(row["nbt"]) + stock = data.pop("Items") + row["nbt"] = data.snbt() + doc["snapshot"].append(dict(pos=[-61, 5, 70], block="minecraft:chest", state="chest", + nbt=nbtlib.Compound({"Items": stock}).snbt())) + self.assertEqual(7, broad.drill_state(station, doc)["S-05:down-facing"]["stock"]) + + def test_energy_loss_during_restart_is_not_accepted(self): + before = self.states(True) + before["E-06:up-facing"]["energyBits"] = [1132068864] + before["E-06:up-facing"]["energy"] = 250 + with self.assertRaisesRegex(AssertionError, "Restart changed"): + broad.restarted_state(before, self.states(True)) + + def test_report_cannot_drop_an_assertion_or_station(self): + station = self.stations[0] + record = dict(station=station["id"], minecraft="1.15.2", status="MANUAL_REQUIRED", + assertions=[dict(id=a["id"], status="PASS") for a in station["assertions"]], + manualChecks=station["manualChecks"]) + self.assertEqual(6, broad.assertions_state({station["id"]: record}, [station])["assertions"]["PASS"]) + with self.assertRaisesRegex(AssertionError, "omitted"): + broad.assertions_state({}, [station]) + record["assertions"].pop() + with self.assertRaisesRegex(AssertionError, "assertion set changed"): + broad.assertions_state({station["id"]: record}, [station]) + + def test_decoupled_modset_cannot_silently_contain_base_metals(self): + expected = dict(minecraft="1.15.2", forge="31.2.57", poweradvantage="2.4.6.115021") + broad.loaded_mods({"S-05": dict(mods=expected)}, expected) + actual = dict(expected, basemetals="3.0.1.115021") + with self.assertRaisesRegex(AssertionError, "loaded mod identities"): + broad.loaded_mods({"S-05": dict(mods=actual)}, expected) + + def test_live_broad_staging_explicitly_disables_frozen_migration_mode(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + (runtime / "advantage-migration.allow").touch() + broad.authorize_broad(runtime) + self.assertTrue((runtime / "advantage-works.allow").is_file()) + self.assertTrue((runtime / "migration-operate.allow").is_file()) + + def test_works_world_retains_original_no_structure_and_seed_settings(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + properties = runtime / "server.properties" + properties.write_text("level-name=AdvantageWorksMigration\nserver-port=25584\ngenerate-structures=true\n") + broad.configure_broad(runtime, 25588) + result = dict(line.split("=", 1) for line in properties.read_text().splitlines()) + self.assertEqual("false", result["generate-structures"]) + self.assertEqual("advantage-works-1.15.2-v1", result["level-seed"]) + self.assertEqual("25588", result["server-port"]) + self.assertEqual("0", result["difficulty"]) + self.assertEqual("true", result["enable-command-block"]) + self.assertEqual("false", result["spawn-npcs"]) + self.assertEqual("AdvantageWorksMigration", result["level-name"]) + + def test_required_integration_skip_is_explained_by_loaded_mods(self): + station = dict(id="W-03", assertions=[dict(id="mineralogy", requiredMods=["mineralogy"])]) + record = dict(station="W-03", minecraft="1.15.2", status="MANUAL_REQUIRED", mods={}, manualChecks=["GUI"], + assertions=[dict(id="mineralogy", status="SKIP")]) + self.assertEqual({"SKIP": 1}, broad.assertions_state({"W-03": record}, [station])["assertions"]) + record["mods"]["mineralogy"] = "6.0.1.115021" + with self.assertRaisesRegex(AssertionError, "unexplained skip"): + broad.assertions_state({"W-03": record}, [station]) + + def test_duplicate_assertions_cannot_be_counted_as_coverage(self): + station = dict(id="W-03", assertions=[dict(id="one")]) + record = dict(station="W-03", minecraft="1.15.2", status="MANUAL_REQUIRED", manualChecks=["GUI"], + assertions=[dict(id="one", status="PASS"), dict(id="one", status="PASS")]) + with self.assertRaisesRegex(AssertionError, "assertion set changed"): + broad.assertions_state({"W-03": record}, [station]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_campaign.py b/test-rig/migration/test_native115_campaign.py new file mode 100644 index 0000000..7a64fdd --- /dev/null +++ b/test-rig/migration/test_native115_campaign.py @@ -0,0 +1,110 @@ +"""Source approval requires a recorded stopped-runtime receipt, never a fresh hash.""" + +from argparse import Namespace +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from matrix import WORLD, digest, fingerprint, write_json +from native115 import RIG +from native115_campaign import ACCEPTED_POWER114, REQUIRED, generate114, require_source114_receipt, source_row + + +class SourceReceiptTests(unittest.TestCase): + def setUp(self): + root = RIG / "build/port-evidence" + root.mkdir(parents=True, exist_ok=True) + self.temporary = tempfile.TemporaryDirectory(dir=root) + self.root = Path(self.temporary.name) + + def tearDown(self): + self.temporary.cleanup() + + def receipt(self): + source = self.root / "disposable114" + (source / WORLD).mkdir(parents=True) + (source / WORLD / "level.dat").write_bytes(b"immutable stopped source witness") + manifest = self.root / "accepted114.json" + write_json(manifest, {}) + receipt_path = self.root / "receipt.json" + receipt = dict(status="PASS", acceptedPower114=ACCEPTED_POWER114, sourceRuntime=str(source.resolve()), + runtimeFingerprint=fingerprint(source), worldFingerprint=fingerprint(source / WORLD), + target114Manifest=str(manifest), target114ManifestSha256=digest(manifest), blocks=355, tiles=332) + write_json(receipt_path, receipt) + row = dict(id="native114/full", kind="frozen", source=str(source), source114Receipt=str(receipt_path), + source114ReceiptSha256=digest(receipt_path), sourceStatus="GENERATED_DISPOSABLE_114_VERIFIED") + return source, receipt_path, row + + def test_recorded_whole_runtime_and_world_fingerprints_are_both_verified(self): + source, _, row = self.receipt() + with patch("native115_campaign.native114.pinned_profile"): + require_source114_receipt(row) + (source / WORLD / "level.dat").write_bytes(b"changed") + with self.assertRaisesRegex(AssertionError, "fingerprint changed"): + require_source114_receipt(row) + + def test_counts_cannot_be_downgraded_even_with_a_new_receipt_digest(self): + source, receipt_path, row = self.receipt() + import json + receipt = json.loads(receipt_path.read_text()) + receipt["tiles"] = 331 + write_json(receipt_path, receipt) + row["source114ReceiptSha256"] = digest(receipt_path) + with patch("native115_campaign.native114.pinned_profile"), self.assertRaisesRegex(AssertionError, "355-block/332-tile"): + require_source114_receipt(row) + + def test_generated_source_metadata_is_disjoint_from_the_stopped_runtime(self): + bundle = self.root / "generated" + source = bundle / "native114-full/runtime" + (source / WORLD).mkdir(parents=True) + (source / WORLD / "level.dat").write_bytes(b"stopped world") + write_json(source / "migration-restarted.json", []) + write_json(source / "migration-fixture.json", {}) + manifest = self.root / "accepted114.json" + write_json(manifest, {}) + before = fingerprint(source) + with patch("native115_campaign.native115.pinned_profile"): + with self.assertRaisesRegex(ValueError, "overlaps a protected input"): + source_row(bundle, "frozen", "native114/full", source, manifest, "target114", "migration-restarted.json") + row = source_row(bundle / "source-records", "frozen", "native114/full", source, manifest, + "target114", "migration-restarted.json") + self.assertEqual(fingerprint(source), before) + self.assertTrue(Path(row["sourceHashes"]).is_relative_to(bundle / "source-records")) + self.assertEqual(digest(Path(row["sourceHashes"])), row["sourceHashesSha256"]) + + def test_source_generation_does_not_launch_an_incomplete_comprehensive_fixture(self): + source = self.root / "approved13" + source.mkdir() + write_json(source / "migration-restarted.json", []) + manifest, hashes = self.root / "pins.json", self.root / "hashes.json" + write_json(manifest, {}) + write_json(hashes, {}) + recipe = dict(source=str(source), sourceManifest=str(manifest), sourceManifestSha256=digest(manifest), + sourceHashes=str(hashes), sourceHashesSha256=digest(hashes), sourceKey="target113", + sourceSnapshot="migration-restarted.json", sourceSnapshotSha256=digest(source / "migration-restarted.json")) + rows = [] + for kind, names in REQUIRED.items(): + for name in sorted(names): + row = dict(kind=kind, id=name) + if name.startswith("native114/"): + row.update(native114SourceRecipe=recipe, source114Manifest=str(manifest), + source114ManifestSha256=digest(manifest), sourceStatus="REQUIRES_DISPOSABLE_114_GENERATION") + rows.append(row) + # Put the comprehensive source first; no process is needed to reject it. + rows.sort(key=lambda row: row["id"] != "native114/full") + plan = self.root / "routes.json" + write_json(plan, dict(format=1, acceptedPower114=ACCEPTED_POWER114, routes=rows)) + settings = Namespace(routes=plan, java8=self.root / "java.exe", output=self.root / "new", port=25595) + before = fingerprint(source) + with patch("native115_campaign.native114.pinned_profile", return_value=(self.root / "seed", "launcher", [])), \ + patch("native115_campaign.native114.run") as launch: + with self.assertRaisesRegex(AssertionError, "355-block/332-tile"): + generate114(settings) + launch.assert_not_called() + self.assertEqual(fingerprint(source), before) + self.assertTrue((settings.output / "native114-full/source114-receipt.json").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_connected.py b/test-rig/migration/test_native115_connected.py new file mode 100644 index 0000000..02bb1cc --- /dev/null +++ b/test-rig/migration/test_native115_connected.py @@ -0,0 +1,253 @@ +"""Native115 connected destination safety and unchanged-oracle regression tests.""" + +import copy +import json +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +import zipfile +from unittest.mock import Mock, patch + +import connected +from matrix import WORLD, digest, fingerprint, write_json +from native115 import LAUNCHER, RIG +import native115_connected as native +from test_connected import advanced, nbt, sample + + +BOOT_PROOF = ("Starting minecraft server version 1.15.2\n" + "Forge mod loading, version 31.2.57, for MC 1.15.2 with MCP 20200515.085601\n") + + +class NativeConnectedTest(unittest.TestCase): + def setUp(self): + root = RIG / "build/port-evidence" + root.mkdir(parents=True, exist_ok=True) + self.temp = tempfile.TemporaryDirectory(dir=root) + self.root = Path(self.temp.name) + self.definition = connected.fixture("original110") + + def tearDown(self): + self.temp.cleanup() + + def source(self): + source = self.root / "original110" + (source / WORLD).mkdir(parents=True) + (source / WORLD / "level.dat").write_bytes(b"unit-fixture-only-no-minecraft-process") + (source / "mods").mkdir() + jar = source / "mods/old.jar" + jar.write_bytes(b"archived-unit-test-artifact") + write_json(source / "input-jars.json", [{"file": jar.name, "sha256": digest(jar)}]) + manifest = self.root / "profiles.json" + write_json(manifest, {"original110": {"seed": str(source), "launcher": "forge-old.jar", + "jars": [{"path": str(jar), "sha256": digest(jar)}]}}) + write_json(source / "connected-fixture.json", self.definition) + write_json(source / "migration-fixture.json", self.definition["blocks"]) + cold, worked = sample(), advanced(sample()) + write_json(source / "connected-cold.json", cold) + write_json(source / "connected-source.json", worked) + write_json(source / "migration-source.json", worked["snapshot"]) + write_json(source / "fixture-hashes.json", fingerprint(source / WORLD)) + write_json(self.root / "connected-results.json", {"status": "FAIL", + "sources": {"original110": {"status": "PASS"}}, "routes": {"old-bridge": {"status": "FAIL"}}}) + return SimpleNamespace(source=source, source_manifest=manifest, source_key="original110", + source_hashes=source / "fixture-hashes.json", source_checkpoint="source", + target_manifest=self.root / "target.json", output=self.root / "new-output", + port=25576, ticks=64, java8=Path("unused-java8")) + + def test_only_observation_save_and_stop_commands_are_permitted(self): + for command in ("stop", "save-all", "save-all flush", "advconnected snapshot imported", + "advconnected step 1 first", "advconnected step 4000 last"): + native.require_command(command) + + def test_build_reset_refill_and_invalid_tick_commands_are_rejected(self): + for command in ("advconnected build source", "advworks reset all", "setblock 0 80 0 stone", + "data merge block 0 80 0 {}", "advconnected step 0 bad", "advconnected step 4001 bad", + "advconnected snapshot ok\nadvworks build all", "advconnected snapshot ../escape"): + with self.subTest(command=command), self.assertRaises(AssertionError): + native.require_command(command) + + def test_operation_cannot_start_before_preservation_gate(self): + server = native.NativeConnectedServer.__new__(native.NativeConnectedServer) + server.loaded_verified = False + with patch.object(connected.ConnectedServer, "send") as sender, self.assertRaises(AssertionError): + server.send("advconnected step 64 worked") + sender.assert_not_called() + + def test_failed_loaded_callback_never_opens_operation_gate(self): + server = native.NativeConnectedServer.__new__(native.NativeConnectedServer) + server.loaded_verified = False + server.on_loaded = Mock(side_effect=AssertionError("lost saved progress")) + with patch.object(connected.ConnectedServer, "observe"), self.assertRaises(AssertionError): + server.observe("snapshot", "converted-loaded") + self.assertFalse(server.loaded_verified) + + def test_valid_destination_document_uses_existing_state_oracle(self): + self.assertEqual(native.require_document(sample(), self.definition, destination=True), connected.state(sample())) + + def test_missing_duplicate_and_moved_witnesses_fail(self): + for mutation in (lambda rows: rows.pop(), lambda rows: rows.append(copy.deepcopy(rows[0])), + lambda rows: rows[0].update(x=99)): + document = sample() + mutation(document["snapshot"]) + with self.assertRaises(AssertionError): + native.require_document(document, self.definition, destination=True) + + def test_rebuild_or_unfrozen_snapshot_is_not_preservation(self): + for field, value in (("buildCallsThisProcess", 1), ("held", False)): + document = sample() + document[field] = value + with self.assertRaises(AssertionError): + native.require_document(document, self.definition, destination=True) + + def test_unchanged_oracle_rejects_quantity_loss_and_wrong_drill_products(self): + for station, contents in (("C-ELECTRIC", '{Items:[]}'), + ("C-EDRILL", '{Items:[{id:"minecraft:gravel",Count:9b}]}')): + document = advanced(sample()) + nbt(connected.entries(document)[station + ("-drill" if station.endswith("DRILL") else "-furnace")], contents) + with self.assertRaises(AssertionError): + native.require_document(document, self.definition, destination=True) + + def test_idle_and_nonmeasured_intervals_still_fail_existing_operation_oracle(self): + before = sample() + idle = copy.deepcopy(before) + idle.update(requestedTicks=64, advancedTicks=64) + self.assertEqual(connected.operation(before, idle, 64)["status"], "FAIL") + worked = advanced(before) + worked["advancedTicks"] = 0 + self.assertEqual(connected.operation(before, worked, 64)["status"], "FAIL") + + def test_finite_water_creation_remains_failure(self): + before = sample() + worked = advanced(before) + nbt(connected.entries(worked)["C-STEAM-water-0"], '{Tank:{FluidName:"water",Amount:10001}}') + self.assertEqual(connected.operation(before, worked, 64)["status"], "FAIL") + + def test_boot_proof_needs_both_native_version_and_clean_log(self): + log = self.root / "console.log" + log.write_text(BOOT_PROOF) + native.require_native_boot(log) + for text in (BOOT_PROOF.replace("1.15.2", "1.13.2"), BOOT_PROOF + "[main/ERROR] native failure\n", + BOOT_PROOF + "java.lang.NoSuchMethodError: example\n"): + log.write_text(text) + with self.assertRaises(AssertionError): + native.require_native_boot(log) + + def test_explicit_source_pins_and_historical_failures_are_preserved(self): + args = self.source() + before = fingerprint(args.source) + definition, snapshot, verdict, actual, historical = native.verify_source(args) + self.assertEqual(definition, self.definition) + self.assertEqual(verdict["status"], "PASS") + self.assertEqual(historical["status"], "FAIL") + self.assertEqual(actual, before) + self.assertEqual(fingerprint(args.source), before) + + def test_changed_source_world_fails_before_staging(self): + args = self.source() + (args.source / WORLD / "level.dat").write_bytes(b"changed") + with patch.object(native, "prepare_target") as prepare, self.assertRaises(AssertionError): + native.run(args) + prepare.assert_not_called() + self.assertFalse(args.output.exists()) + + def test_changed_source_jar_or_wrong_profile_key_fails(self): + args = self.source() + args.source_key = "maintained110" + with self.assertRaises(ValueError): + native.verify_source(args) + args.source_key = "original110" + (args.source / "mods/old.jar").write_bytes(b"changed") + with self.assertRaises(AssertionError): + native.verify_source(args) + + def test_nonconnected_operation_fixture_cannot_be_reconstructed(self): + args = self.source() + (args.source / "connected-fixture.json").unlink() + with patch.object(native, "prepare_target") as prepare, self.assertRaises(FileNotFoundError): + native.run(args) + prepare.assert_not_called() + + def test_target_prepare_copies_only_fixture_and_has_no_build_authority(self): + args = self.source() + (args.source / "connected-build.allow").touch() + (args.source / "connected-built.json").write_text("{}") + before = fingerprint(args.source) + seed = self.root / "native-seed" + (seed / "libraries").mkdir(parents=True) + (seed / LAUNCHER).write_bytes(b"unit-test-launcher-not-executed") + jar = self.root / "target.jar" + jar.write_bytes(b"unit-test-target-not-executed") + runtime = self.root / "target" + pins = {"launcher_sha256": digest(seed / LAUNCHER), "jars": [{"path": str(jar), "sha256": digest(jar)}]} + self.assertEqual(native.prepare_target(runtime, (seed, LAUNCHER, [jar]), self.definition, 25576, args.source, pins), LAUNCHER) + self.assertEqual(fingerprint(runtime / WORLD), fingerprint(args.source / WORLD)) + self.assertFalse((runtime / "connected-build.allow").exists()) + self.assertFalse((runtime / "connected-built.json").exists()) + self.assertIn("server-port=25576", (runtime / "server.properties").read_text()) + self.assertNotIn("3;minecraft:bedrock", (runtime / "server.properties").read_text()) + self.assertEqual(fingerprint(args.source), before) + + def test_non_native_launcher_is_rejected_without_creating_target(self): + runtime = self.root / "target" + with self.assertRaises(ValueError): + native.prepare_target(runtime, (self.root, "forge-1.13.2.jar", []), self.definition, 25576, self.root, {}) + self.assertFalse(runtime.exists()) + + def target(self, args): + seed = self.root / "seed" + (seed / "libraries").mkdir(parents=True) + (seed / LAUNCHER).write_bytes(b"unit-test-launcher-never-executed") + jars = [] + for identity in ("poweradvantage", "steamadvantage", "electricadvantage", "advantageworkstest", "orespawn"): + jar = seed / (identity + ".jar") + with zipfile.ZipFile(jar, "w") as archive: + archive.writestr("META-INF/mods.toml", f'[[mods]]\nmodId="{identity}"\nversion="1.0.0.115021"\n') + jars.append(dict(path=str(jar), id=identity, version="1.0.0.115021", sha256=digest(jar))) + write_json(args.target_manifest, {"target115": dict(seed=str(seed), launcher=LAUNCHER, + launcher_sha256=digest(seed / LAUNCHER), jars=jars)}) + + def test_runtime_failure_is_retained_and_source_is_still_rechecked(self): + args = self.source() + self.target(args) + before = fingerprint(args.source) + with patch.object(native.socket, "socket"), patch.object(native, "prepare_target", side_effect=RuntimeError("retained native failure")), self.assertRaises(RuntimeError): + native.run(args) + report = native.read_json(args.output / "native115-connected-results.json") + self.assertEqual(report["status"], "FAIL") + self.assertEqual(report["failure"], "retained native failure") + self.assertEqual(report["sourceRuntimeAfter"], before) + self.assertEqual(report["sourcePreservation"]["status"], "PASS") + + def test_source_change_on_failure_is_reported_not_hidden(self): + args = self.source() + self.target(args) + def changed(*unused): + (args.source / WORLD / "level.dat").write_bytes(b"unexpected-source-change") + raise RuntimeError("native failure") + with patch.object(native.socket, "socket"), patch.object(native, "prepare_target", side_effect=changed), self.assertRaisesRegex(AssertionError, "Protected source"): + native.run(args) + report = native.read_json(args.output / "native115-connected-results.json") + self.assertEqual(report["status"], "FAIL") + self.assertEqual(report["sourcePreservation"]["status"], "FAIL") + + def test_candidate_change_after_preflight_cannot_replace_pinned_bytes(self): + args = self.source() + seed = self.root / "seed" + (seed / "libraries").mkdir(parents=True) + (seed / LAUNCHER).write_bytes(b"launcher-not-executed") + jar = self.root / "candidate.jar" + jar.write_bytes(b"pinned") + pins = {"launcher_sha256": digest(seed / LAUNCHER), "jars": [{"path": str(jar), "sha256": digest(jar)}]} + jar.write_bytes(b"evolving-after-preflight") + with self.assertRaises(AssertionError): + native.prepare_target(self.root / "target", (seed, LAUNCHER, [jar]), self.definition, 25576, args.source, pins) + + def test_continued_output_cannot_promote_idle_machinery(self): + self.assertEqual(native.continued_output(sample(), advanced(sample()))["status"], "PASS") + self.assertEqual(native.continued_output(sample(), sample())["status"], "FAIL") + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_endurance.py b/test-rig/migration/test_native115_endurance.py new file mode 100644 index 0000000..f29c18b --- /dev/null +++ b/test-rig/migration/test_native115_endurance.py @@ -0,0 +1,169 @@ +"""Native transport guards; historical material/energy oracles stay inherited.""" + +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import nbtlib +import endurance +from matrix import WORLD, fingerprint, write_json +from native115_endurance import (FRESH_FIXTURE_SEED, NativeEnduranceRig, audit, + configure_new_fresh_fixture, prove_fresh_fixture_before_build, + verify_fresh_fixture_proof, world_info) + + +class NativeEnduranceTests(unittest.TestCase): + def test_batch_and_conservation_oracles_are_inherited(self): + self.assertIs(NativeEnduranceRig.batch, endurance.Rig.batch) + self.assertIs(NativeEnduranceRig.invariants, endurance.Rig.invariants) + self.assertIs(NativeEnduranceRig.inventory, endurance.Rig.inventory) + self.assertIs(NativeEnduranceRig.seed, endurance.Rig.seed) + self.assertIs(NativeEnduranceRig.structure, endurance.Rig.structure) + + def test_restart_selects_first_tick_without_output_or_inventory_inspection(self): + with tempfile.TemporaryDirectory() as directory: + definitions = {s: dict(origin=[0, 0, 0], boundsMin=[0, 0, 0], boundsMax=[31, 20, 31]) + for s in endurance.CHECKED} + rig = NativeEnduranceRig("test", Path(directory), None, None, definitions) + ticks = iter([200, 200, 201]) + observations, commands = [], [] + def checkpoint(label): + observations.append(label) + rig.documents = {"W-01": {"worldTime": next(ticks)}} + rig.checkpoint = checkpoint + rig.command = lambda command, response=None: commands.append(command) + with patch.object(endurance.Rig, "boot"), patch("native115_endurance.time.sleep"): + rig.boot("endurance-restart") + self.assertEqual(len(observations), 3) + self.assertEqual(len(commands), len(endurance.CHECKED)) + self.assertTrue(all(c == "forceload add 0 0 31 31" for c in commands)) + self.assertTrue((Path(directory) / "native-restart-readiness.json").is_file()) + + def test_restart_without_measured_tick_fails(self): + with tempfile.TemporaryDirectory() as directory: + definitions = {s: dict(origin=[0, 0, 0], boundsMin=[0, 0, 0], boundsMax=[31, 20, 31]) + for s in endurance.CHECKED} + rig = NativeEnduranceRig("test", Path(directory), None, None, definitions) + rig.command = lambda *args: None + rig.checkpoint = lambda label: rig.documents.update({"W-01": {"worldTime": 200}}) + with patch.object(endurance.Rig, "boot"), patch("native115_endurance.time.sleep"): + with self.assertRaisesRegex(TimeoutError, "No actual native tick"): + rig.boot("endurance-restart") + + def test_initial_boot_does_not_restore_or_rebuild_anything(self): + rig = NativeEnduranceRig("test", Path("unused"), None, None, {}) + rig.command = lambda *args: self.fail("Initial boot issued a command") + with patch.object(endurance.Rig, "boot"): + rig.boot("endurance") + + def test_smoke_result_never_qualifies_as_endurance(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_json(root / "native115-endurance-results.json", dict(status="SMOKE_PASS_NOT_ENDURANCE")) + with self.assertRaisesRegex(AssertionError, "No completed native endurance"): + audit(root) + + def test_missing_native_profile_fails(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_json(root / "native115-endurance-results.json", dict( + status="PASS", duration_seconds=1800, interval_seconds=60, + source_fixture_unchanged=True, helpers_unchanged=True, profiles={})) + with self.assertRaisesRegex(AssertionError, "Missing native environment"): + audit(root) + + +class FreshFixtureIsolationTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.runtime = Path(self.temporary.name) + (self.runtime / "server.properties").write_text( + "max-tick-time=60000\nserver-port=25590\nlevel-seed=old\n" + "generate-structures=true\ngenerate-structures=true\n", encoding="utf-8") + + def level(self, features=0, seed=FRESH_FIXTURE_SEED): + path = self.runtime / WORLD / "level.dat" + path.parent.mkdir(exist_ok=True) + nbtlib.File({"Data": nbtlib.Compound({"MapFeatures": nbtlib.Byte(features), + "RandomSeed": nbtlib.Long(seed), "generatorName": nbtlib.String("flat")})}, + gzipped=True).save(path) + return path + + def rig(self, block="minecraft:air"): + rig = NativeEnduranceRig("test", self.runtime, None, None, {}) + def snapshot(label): + write_json(self.runtime / f"migration-{label}.json", [dict( + id="poi-target", x=-126, y=5, z=27, actualName=block, properties={})]) + rig.server = type("FakeServer", (), {"snapshot": staticmethod(snapshot)})() + rig.command = lambda command, response=None: self.assertEqual((command, response), + ("save-all flush", "Saved the game")) + return rig + + def test_new_world_only_changes_two_creation_settings_and_keeps_watchdog(self): + proof = configure_new_fresh_fixture(self.runtime) + text = (self.runtime / "server.properties").read_text() + self.assertTrue(proof["existing_world_absent"]) + self.assertEqual(text.count("generate-structures="), 1) + self.assertEqual(text.count("level-seed="), 1) + self.assertIn("generate-structures=false\n", text) + self.assertIn(f"level-seed={FRESH_FIXTURE_SEED}\n", text) + self.assertIn("max-tick-time=60000\nserver-port=25590\n", text) + + def test_existing_imported_world_is_refused_before_any_write(self): + self.level(features=1, seed=42) + before = fingerprint(self.runtime) + with self.assertRaisesRegex(AssertionError, "existing/imported world"): + configure_new_fresh_fixture(self.runtime) + self.assertEqual(fingerprint(self.runtime), before) + self.assertEqual(world_info(self.runtime / WORLD / "level.dat")["MapFeatures"], 1) + + def test_actual_seed_mapfeatures_and_air_proof_pass_and_are_replayable(self): + configuration = configure_new_fresh_fixture(self.runtime) + self.level() + proof = prove_fresh_fixture_before_build(self.rig(), configuration) + self.assertEqual(proof["status"], "PASS") + self.assertEqual(verify_fresh_fixture_proof(self.runtime, proof), + dict(MapFeatures=0, RandomSeed=FRESH_FIXTURE_SEED, generatorName="flat")) + self.assertEqual(proof["critical_block"]["actualName"], "minecraft:air") + + def test_property_false_does_not_substitute_for_actual_worldinfo_zero(self): + configuration = configure_new_fresh_fixture(self.runtime) + self.level(features=1) + with self.assertRaisesRegex(AssertionError, "MapFeatures is not zero"): + prove_fresh_fixture_before_build(self.rig(), configuration) + + def test_a_different_seed_is_rejected_even_with_structures_disabled(self): + configuration = configure_new_fresh_fixture(self.runtime) + self.level(seed=123) + with self.assertRaisesRegex(AssertionError, "seed changed"): + prove_fresh_fixture_before_build(self.rig(), configuration) + + def test_composter_is_rejected_even_with_correct_seed_and_worldinfo(self): + configuration = configure_new_fresh_fixture(self.runtime) + self.level() + with self.assertRaisesRegex(AssertionError, "not AIR"): + prove_fresh_fixture_before_build(self.rig("minecraft:composter"), configuration) + + def test_missing_guard_and_tampered_raw_evidence_fail(self): + configuration = configure_new_fresh_fixture(self.runtime) + self.level() + proof = prove_fresh_fixture_before_build(self.rig(), configuration) + with self.assertRaisesRegex(AssertionError, "new-world isolation guard"): + verify_fresh_fixture_proof(self.runtime, dict(proof, existing_world_absent=False)) + (self.runtime / "migration-fresh-isolation-before-build.json").write_text("[]") + with self.assertRaisesRegex(AssertionError, "evidence changed"): + verify_fresh_fixture_proof(self.runtime, proof) + + def test_no_new_run_can_qualify_without_fresh_isolation_proof(self): + write_json(self.runtime / "native115-endurance-results.json", dict( + status="PASS", duration_seconds=1800, interval_seconds=60, + source_fixture_unchanged=True, helpers_unchanged=True, + profiles={"fresh115": {}, "original110-chained113-native115": {}})) + with self.assertRaisesRegex(AssertionError, "Missing fresh isolation proof"): + audit(self.runtime) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_geometry.py b/test-rig/migration/test_native115_geometry.py new file mode 100644 index 0000000..aa31723 --- /dev/null +++ b/test-rig/migration/test_native115_geometry.py @@ -0,0 +1,139 @@ +"""Engine geometry and independent historical-oracle preservation contracts.""" + +import copy +import json +from pathlib import Path +import unittest +import tempfile + +from matrix import digest +from native115 import RIG +from native115_geometry import require_geometry +from native115_campaign import ACCEPTED_POWER114, REQUIRED, require_routes, require_source114_receipt +from native115_harvest import EXPECTED, require_checks, mark_runtime + + +class GeometryTests(unittest.TestCase): + def sample(self): + return dict(ticketGeometry=dict(fullLevel=33, entityTickingLevel=31, maxDistance=11, + lastHolderLevel=44, holderRadius=13, observedForcedLevels=[31])) + + def test_real_forced_ticket_level_and_generation_halo_are_both_required(self): + require_geometry(self.sample(), forced=True) + for key in ("fullLevel", "entityTickingLevel", "maxDistance", "lastHolderLevel", "holderRadius"): + value = self.sample() + value["ticketGeometry"][key] += 1 + with self.subTest(key=key), self.assertRaises(AssertionError): + require_geometry(value) + + def test_empty_tickets_only_allowed_after_release(self): + value = self.sample() + value["ticketGeometry"]["observedForcedLevels"] = [] + require_geometry(value) + with self.assertRaises(AssertionError): + require_geometry(value, forced=True) + + def test_missing_or_historical_assumptions_cannot_count_as_live_geometry(self): + with self.assertRaises(AssertionError): + require_geometry({}) + value = self.sample() + value["ticketGeometry"]["observedForcedLevels"] = [30, 31] + with self.assertRaises(AssertionError): + require_geometry(value) + + +class HarvestTests(unittest.TestCase): + def test_disposable_harvest_runtime_has_both_required_markers(self): + with tempfile.TemporaryDirectory() as folder: + mark_runtime(Path(folder)) + self.assertEqual({p.name for p in Path(folder).iterdir()}, + {"advantage-native-harvest.allow", "advantage-works.allow"}) + + def sample(self): + rows = [] + for name, (level, allowed, silk, minimum, maximum, trials) in EXPECTED.items(): + rows.append(dict(id=name, status="PASS", historicalHarvestLevel=level, harvestAllowed=allowed, + silkTouch=silk, trials=trials, actualDropCounts=[minimum] * trials, + actualSolid=name.startswith(("sulfur/", "lithium/", "wire/")), + fullCollision=not name.startswith("wire/"), + connectionMask=int(name.split("/")[1]) if name.startswith(("wire/", "scaffold/")) else -1)) + rows.append(dict(id="solar/native-solidity", status="PASS", actualSolid=True)) + return dict(label="exercise", minecraft="1.15.2", status="PASS", checks=rows) + + def test_complete_native_survival_witness_set(self): + require_checks(self.sample(), "exercise") + self.assertEqual(len(EXPECTED), 270) + + def test_json_presence_or_pass_label_without_real_samples_is_not_enough(self): + value = self.sample() + value["checks"][0]["actualDropCounts"] = [] + with self.assertRaises(AssertionError): + require_checks(value, "exercise") + + def test_failure_tool_drop_silk_drop_quantity_and_level_remain_strict(self): + for name, field, bad in (("lithium/wood-fails", "harvestAllowed", True), + ("lithium/stone", "historicalHarvestLevel", 0), + ("sulfur/silk", "actualDropCounts", [2]), + ("sulfur/wood", "actualDropCounts", [5] * 8), + ("sulfur/hand-fails", "actualDropCounts", [1])): + value = self.sample() + next(row for row in value["checks"] if row["id"] == name)[field] = bad + with self.subTest(name=name), self.assertRaises(AssertionError): + require_checks(value, "exercise") + + def test_missing_or_duplicated_witnesses_fail(self): + value = self.sample() + value["checks"][-1] = copy.deepcopy(value["checks"][0]) + with self.assertRaises(AssertionError): + require_checks(value, "exercise") + + def test_all_wire_scaffold_states_and_legacy_solar_solidness_are_strict(self): + for name, field, bad in (("wire/63/hand", "connectionMask", 0), + ("wire/0/hand", "actualSolid", False), + ("wire/31/silk", "fullCollision", True), + ("scaffold/31/silk", "actualSolid", True), + ("scaffold/0/hand", "fullCollision", False), + ("solar/native-solidity", "actualSolid", False)): + value = self.sample() + next(row for row in value["checks"] if row["id"] == name)[field] = bad + with self.subTest(name=name), self.assertRaises(AssertionError): + require_checks(value, "exercise") + + +class PortContracts(unittest.TestCase): + def test_historical_adapters_profiles_and_semantic_oracles_are_byte_identical(self): + ledger = json.loads((RIG / "migration/native115-historical-inputs.json").read_text()) + self.assertEqual(ledger["sourceCommit"], ACCEPTED_POWER114) + self.assertGreater(len(ledger["hashes"]), 100) + for relative, checksum in ledger["hashes"].items(): + with self.subTest(path=relative): + self.assertEqual(digest(RIG / relative), checksum) + + def test_all_thirteen_old_routes_and_comprehensive_native114_source_are_required(self): + self.assertEqual(len(REQUIRED["frozen"]), 14) + self.assertEqual(len(REQUIRED["connected"]), 10) + document = dict(format=1, acceptedPower114=ACCEPTED_POWER114, + routes=[dict(kind=kind, id=name) for kind, names in REQUIRED.items() for name in names]) + require_routes(document) + for kind in REQUIRED: + value = copy.deepcopy(document) + index = next(i for i, row in enumerate(value["routes"]) if row["kind"] == kind) + value["routes"].pop(index) + with self.subTest(kind=kind), self.assertRaises(AssertionError): + require_routes(value) + + def test_accepted114_cannot_be_replaced_by_another_passing_legacy_case(self): + document = dict(format=1, acceptedPower114=ACCEPTED_POWER114, + routes=[dict(kind=kind, id=name) for kind, names in REQUIRED.items() for name in names]) + victim = next(row for row in document["routes"] if row["kind"] == "connected" and row["id"].startswith("native114/")) + victim["id"] = "legacy/original110" + with self.assertRaises(AssertionError): + require_routes(document) + + def test_freshly_fingerprinting_an_unverified_old_destination_is_not_approval(self): + with self.assertRaisesRegex(AssertionError, "not approval"): + require_source114_receipt(dict(id="native114/full", kind="frozen", sourceStatus="FRESH_HASH_ONLY")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_harvest.py b/test-rig/migration/test_native115_harvest.py new file mode 100644 index 0000000..66a9095 --- /dev/null +++ b/test-rig/migration/test_native115_harvest.py @@ -0,0 +1,142 @@ +"""Harvest fixture isolation and unchanged-oracle tests; never start a server.""" + +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import unittest +from uuid import UUID + +import native115_harvest as native + + +def sample(label="exercise"): + rows = [] + identity = 1 + for name, (level, allowed, silk, minimum, maximum, trials) in native.EXPECTED.items(): + evidence = [] + for trial in range(trials): + drops = [] + if minimum: + drops.append(dict(entityId=identity, uuid=str(UUID(int=identity)), + item="unit:actual-drop", count=minimum, removed=False)) + identity += 1 + evidence.append(dict(trial=trial, worldTime=1, removedBeforeCleanup=trial, + liveBeforeCleanup=int(trial > 0), liveAfterCleanup=0, actualDrops=drops)) + rows.append(dict(id=name, status="PASS", historicalHarvestLevel=level, harvestAllowed=allowed, + silkTouch=silk, trials=trials, actualDropCounts=[minimum] * trials, + connectionMask=int(name.split("/")[1]) if name.startswith(("wire/", "scaffold/")) else -1, + actualSolid=name.startswith(("sulfur/", "lithium/", "wire/")), + fullCollision=not name.startswith("wire/"), dropEvidence=evidence)) + rows.append(dict(id="solar/native-solidity", status="PASS", actualSolid=True)) + return dict(label=label, minecraft="1.15.2", status="PASS", checks=rows, + fixtureIsolation=native.ISOLATION_PROTOCOL) + + +def row(document, name="sulfur/wood"): + return next(entry for entry in document["checks"] if entry["id"] == name) + + +class HarvestIsolationTests(unittest.TestCase): + def test_original_270_harvest_oracles_are_exactly_preserved(self): + checksum = hashlib.sha256(json.dumps(native.EXPECTED, sort_keys=True, separators=(",", ":")).encode()).hexdigest().upper() + self.assertEqual("A43F316D748DFCCB4DA9B72AE771851FFBD30661C56E5609A0D04E0AC0050D5B", checksum) + self.assertEqual(270, len(native.EXPECTED)) + + def test_java_witness_inputs_masks_trial_counts_and_thresholds_are_unchanged(self): + source = (Path(__file__).resolve().parents[1] / + "src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java").read_text() + region = source.split(" String sulfur =", 1)[1].split(" JsonObject solar =", 1)[0] + self.assertEqual("3FF0AC4C13CA1B737679F67128CE2F41C5A0873E89258D456628BA8A309BAA9A", + hashlib.sha256(region.encode()).hexdigest().upper()) + self.assertIn("Actual drop quantity changed: ", source) + self.assertIn('require(count >= minimum && count <= maximum,', source) + self.assertIn('equals(dropId), "Wrong actual drop: "', source) + self.assertIn('bounds, entity -> !entity.removed)', source) + + def test_new_protocol_accepts_all_original_cases_in_each_restart_phase(self): + for label in ("exercise", "restored"): + document = sample(label) + native.require_checks(document, label) + native.require_isolation(document) + + def test_old_or_missing_fixture_protocol_is_rejected(self): + for marker in (None, "native114", "native115-default-entity-query"): + document = sample() + document["fixtureIsolation"] = marker + with self.subTest(marker=marker), self.assertRaisesRegex(AssertionError, "isolation protocol"): + native.require_isolation(document) + + def test_incomplete_trial_audits_cannot_qualify(self): + document = sample() + row(document)["dropEvidence"].pop() + with self.assertRaisesRegex(AssertionError, "per-trial"): + native.require_isolation(document) + + def test_live_remainder_bad_sequence_or_noninteger_cleanup_is_rejected(self): + for field, value in (("liveAfterCleanup", 1), ("liveAfterCleanup", False), + ("removedBeforeCleanup", -1), ("liveBeforeCleanup", -1), + ("trial", 3), ("worldTime", -1)): + document = sample() + row(document)["dropEvidence"][0][field] = value + with self.subTest(field=field, value=value), self.assertRaisesRegex(AssertionError, "not isolated"): + native.require_isolation(document) + + def test_removed_entities_missing_identity_and_count_lies_are_rejected(self): + for field, value in (("removed", True), ("removed", None), ("entityId", "1"), + ("uuid", ""), ("item", ""), ("count", 0), ("count", True), ("count", 2)): + document = sample() + row(document)["dropEvidence"][0]["actualDrops"][0][field] = value + with self.subTest(field=field, value=value), self.assertRaises(AssertionError): + native.require_isolation(document) + + def test_same_entity_cannot_be_counted_in_two_trials_or_two_rows(self): + for across_rows in (False, True): + for field in ("entityId", "uuid"): + document = sample() + first = row(document)["dropEvidence"][0]["actualDrops"][0] + later = row(document, "sulfur/stone")["dropEvidence"][0] if across_rows else row(document)["dropEvidence"][1] + later["actualDrops"][0][field] = first[field] + with self.subTest(across_rows=across_rows, field=field), self.assertRaisesRegex(AssertionError, "repeated"): + native.require_isolation(document) + + def test_no_drop_case_cannot_hide_an_unexpected_live_entity(self): + document = sample() + row(document, "lithium/wood-fails")["dropEvidence"][0]["actualDrops"] = [ + dict(entityId=99999, uuid=str(UUID(int=99999)), item="electricadvantage:sulfur_powder", count=4, removed=False)] + with self.assertRaisesRegex(AssertionError, "disagree"): + native.require_isolation(document) + + def test_failed_identity_witness_reports_actual_failure_not_missing_inventory(self): + document = sample() + document["status"] = "FAIL" + failed = row(document, "sulfur/silk") + failed.update(status="FAIL", failure="Wrong actual drop: 4 sulfur_powder") + with self.assertRaisesRegex(AssertionError, "sulfur/silk: Wrong actual drop: 4 sulfur_powder"): + native.require_checks(document, "exercise") + + def test_complete_inventory_with_failed_aggregate_status_still_fails(self): + document = sample() + document["status"] = "FAIL" + with self.assertRaisesRegex(AssertionError, "witnesses failed"): + native.require_checks(document, "exercise") + + def test_original_drop_bounds_wrong_tools_masks_shapes_and_solar_remain_strict(self): + for name, field, value in (("sulfur/wood", "actualDropCounts", [5] * 8), + ("sulfur/silk", "actualDropCounts", [4]), + ("lithium/wood-fails", "actualDropCounts", [1]), + ("lithium/stone", "historicalHarvestLevel", 0), + ("wire/63/hand", "connectionMask", 0), + ("wire/0/hand", "actualSolid", False), + ("wire/0/hand", "fullCollision", True), + ("scaffold/0/hand", "actualSolid", True), + ("scaffold/0/hand", "fullCollision", False), + ("solar/native-solidity", "actualSolid", False)): + document = sample() + row(document, name)[field] = deepcopy(value) + with self.subTest(name=name, field=field), self.assertRaises(AssertionError): + native.require_checks(document, "exercise") + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_holder_networks.py b/test-rig/migration/test_native115_holder_networks.py new file mode 100644 index 0000000..5b4dcc1 --- /dev/null +++ b/test-rig/migration/test_native115_holder_networks.py @@ -0,0 +1,58 @@ +"""The full native holder halo is wider than the FULL-status halo.""" + +import unittest +from native115_holder_networks import (FORCED_LEVEL, HOLDER_RADIUS, LAST_HOLDER_LEVEL, + bridge_fixtures, extended_fixtures, linear_fixtures) +from native115_ticket_networks import fill_commands +import extended_lifecycle + + +class HolderNetworkTests(unittest.TestCase): + def test_exact_native_holder_threshold_is_not_confused_with_full_threshold(self): + self.assertEqual(FORCED_LEVEL, 31) + self.assertEqual(LAST_HOLDER_LEVEL, 33 + 11) + self.assertEqual(HOLDER_RADIUS, 13) + self.assertGreater(FORCED_LEVEL + HOLDER_RADIUS + 1, LAST_HOLDER_LEVEL) + + def test_every_retained_ticket_is_outside_full_holder_dependency_radius(self): + for definition in (linear_fixtures(), bridge_fixtures()): + for f in definition: + all_chunks = {(x, z) for x in range(f["min"][0] >> 4, (f["max"][0] >> 4) + 1) + for z in range(f["min"][2] >> 4, (f["max"][2] >> 4) + 1)} + for group in ("bridge",) if "bridge" in f["groups"] else ("supply", "machine"): + retained = all_chunks - set(map(tuple, f["groups"][group + "halo"])) + self.assertTrue(retained) + self.assertTrue(all(min(max(abs(x-a), abs(z-b)) for a, b in f["groups"][group]) > 13 for x, z in retained)) + + def test_long_paths_and_all_witnesses_stay_inside_separate_bays(self): + for definition in (linear_fixtures(), bridge_fixtures(), extended_fixtures()): + for index, f in enumerate(definition): + for p in f["witnesses"]: + self.assertTrue(all(a <= n <= b for a, n, b in zip(f["min"], p, f["max"]))) + for other in definition[index + 1:]: + self.assertTrue(any(f["max"][a] < other["min"][a] or other["max"][a] < f["min"][a] for a in range(3))) + + def test_paths_are_connected(self): + for f in linear_fixtures() + bridge_fixtures(): + sink = f["witnesses"][1] if "bridge" in f["groups"] else f["witnesses"][4] + path = [f["witnesses"][0], *f["pipes"], sink] + self.assertTrue(all(sum(abs(x-y) for x, y in zip(a,b)) == 1 for a,b in zip(path,path[1:]))) + self.assertEqual(len(path), len(set(map(tuple,path)))) + + def test_drill_geometry_is_not_changed_to_accept_network_results(self): + self.assertEqual(extended_fixtures()[:18], [f for f in extended_lifecycle.fixtures() if "route" in f]) + + def test_native_fill_is_still_bounded_below_vanilla_volume_limit(self): + for f in linear_fixtures() + bridge_fixtures(): + commands = fill_commands([f], "fill " + " ".join(map(str,f["min"]+f["max"])) + " minecraft:air replace") + self.assertEqual(len(commands), 68) + for command in commands: + values = list(map(int, command.split()[1:7])) + volume = 1 + for a,b in zip(values[:3],values[3:]): + volume *= b-a+1 + self.assertLessEqual(volume,32768) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_interop.py b/test-rig/migration/test_native115_interop.py new file mode 100644 index 0000000..2503002 --- /dev/null +++ b/test-rig/migration/test_native115_interop.py @@ -0,0 +1,33 @@ +import unittest + +from native115_interop import require_checks + + +class NativeInteropOracleTests(unittest.TestCase): + def document(self, phase="exercise"): + return dict(phase=phase, status="PASS", checks=[dict(id=str(i), status="PASS") + for i in range(20 if phase == "exercise" else 2)]) + + def test_complete_witness_sets_pass(self): + for phase in ("exercise", "restored"): + require_checks(self.document(phase), phase) + + def test_single_conversion_failure_is_not_hidden_by_overall_status(self): + document = self.document() + document["checks"][2].update(status="FAIL", failure="energy disappeared") + with self.assertRaisesRegex(AssertionError, "energy disappeared"): + require_checks(document, "exercise") + + def test_missing_and_duplicate_witnesses_fail(self): + document = self.document() + document["checks"].pop() + with self.assertRaisesRegex(AssertionError, "Incomplete"): + require_checks(document, "exercise") + document = self.document() + document["checks"][1]["id"] = "0" + with self.assertRaisesRegex(AssertionError, "Duplicate"): + require_checks(document, "exercise") + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_lifecycle.py b/test-rig/migration/test_native115_lifecycle.py new file mode 100644 index 0000000..b4e5c26 --- /dev/null +++ b/test-rig/migration/test_native115_lifecycle.py @@ -0,0 +1,435 @@ +"""Native lifecycle adapter regressions; no Minecraft process or retained world writes.""" + +import argparse +import copy +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from unittest.mock import Mock + +import lifecycle +import native115_lifecycle as native +from matrix import WORLD, digest, fingerprint, write_json +from native115 import RIG + + +class NativeLifecycleTests(unittest.TestCase): + def setUp(self): + root = RIG / "build/port-evidence" + root.mkdir(parents=True, exist_ok=True) + self.temp = tempfile.TemporaryDirectory(dir=root) + self.root = Path(self.temp.name) + + def tearDown(self): + self.temp.cleanup() + + def source_args(self): + source = self.root / "source" + world = source / WORLD + world.mkdir(parents=True) + (world / "level.dat").write_bytes(b"unit-test-world-hash-only") + mods = source / "mods" + mods.mkdir() + jar = mods / "archived-1.13.2.jar" + jar.write_bytes(b"unit-test-pin-not-a-runtime") + write_json(source / "input-jars.json", [{"file": jar.name, "sha256": digest(jar)}]) + history = {key: {"status": "PASS"} for key in ("conversion", "restart", "idempotence")} + write_json(source / "state-comparison.json", history) + write_json(source / "migration-fixture.json", []) + write_json(source / "migration-restarted.json", []) + manifest = self.root / "archived.json" + write_json(manifest, {"target113": {"seed": str(source), "launcher": "forge-1.13.2-25.0.223.jar", + "jars": [{"path": str(jar), "sha256": digest(jar)}]}}) + hashes = self.root / "source-world-hashes.json" + write_json(hashes, fingerprint(world)) + java = self.root / "java.exe" + java.write_bytes(b"not-launched") + target = self.root / "target.json" + write_json(target, {"target115": {"jars": [{"id": key} for key in + ("steamadvantage", "electricadvantage", "basemetals")]}}) + return argparse.Namespace(source=source, source_manifest=manifest, source_key="target113", + source_hashes=hashes, target_manifest=target, java8=java, + output=self.root / "output", port=25580) + + def test_native_subclass_inherits_original_operations_fixtures_and_oracles(self): + self.assertIs(native.lifecycle.LifecycleRun, lifecycle.LifecycleRun) + self.assertIs(native.lifecycle.fixtures, lifecycle.fixtures) + for method in ("build", "midblock", "save_read_guards", "mining_results"): + self.assertIs(getattr(native.NativeLifecycleRun, method), getattr(lifecycle.LifecycleRun, method)) + definition = native.lifecycle.fixtures() + self.assertEqual(len(definition), 15) + drills = [entry for entry in definition if "from" in entry] + self.assertEqual({entry["id"] for entry in drills}, + {prefix + facing for prefix in ("S-MOVE-", "E-MOVE-") for facing in lifecycle.FACINGS}) + for entry in drills: + self.assertNotEqual((entry["from"][0] >> 4, entry["from"][2] >> 4), + (entry["to"][0] >> 4, entry["to"][2] >> 4)) + + def test_full_seven_groups_cannot_pass_missing_or_failed_drills(self): + definition = lifecycle.fixtures() + group = {entry["id"]: {"status": "PASS"} for entry in definition if "from" in entry} + groups = [copy.deepcopy(group) for _ in range(7)] + self.assertTrue(native.require_groups(definition, groups)) + self.assertFalse(native.require_groups(definition, [])) + groups[0].pop("S-MOVE-down") + self.assertFalse(native.require_groups(definition, groups)) + groups[0] = copy.deepcopy(group) + groups[6]["E-MOVE-up"]["status"] = "FAIL" + self.assertFalse(native.require_groups(definition, groups)) + + def test_optional_load_tracing_precedes_build_and_does_not_replace_runner(self): + runner = Mock(runtime=self.root, definition=lifecycle.fixtures()) + runner.build.side_effect = RuntimeError("stop before any real fixture") + with patch.object(native, "require_native_boot"): + with self.assertRaisesRegex(RuntimeError, "stop before"): + native.exercise(runner, "unit", lambda result: None, trace_loads=True) + runner.boot.assert_called_once_with("lifecycle") + runner.probe.assert_called_once_with("trace-loads", "tracing-loads") + self.assertEqual([call[0] for call in runner.mock_calls], ["boot", "probe", "build"]) + + def test_source_preflight_is_read_only(self): + args = self.source_args() + before = fingerprint(args.source) + observed, history = native.verify_source(args) + self.assertEqual(before, observed) + self.assertEqual(before, fingerprint(args.source)) + self.assertEqual(history["restart"]["status"], "PASS") + + def test_changed_source_world_is_rejected(self): + args = self.source_args() + (args.source / WORLD / "level.dat").write_bytes(b"tampered") + with self.assertRaisesRegex(AssertionError, "fingerprint mismatch"): + native.verify_source(args) + + def test_source_mods_must_match_archived_manifest_and_recorded_inputs(self): + args = self.source_args() + (args.source / "mods" / "unexpected.jar").write_bytes(b"extra") + with self.assertRaisesRegex(AssertionError, "source jars differ"): + native.verify_source(args) + + def test_failed_source_state_is_not_reclassified(self): + args = self.source_args() + history = native.read_json(args.source / "state-comparison.json") + history["restart"]["status"] = "FAIL" + write_json(args.source / "state-comparison.json", history) + with self.assertRaisesRegex(AssertionError, "state gate did not pass"): + native.verify_source(args) + + def test_world_hash_keys_are_portable_without_ignoring_files(self): + native.require_equal_hashes({"region/r.0.0.mca": "abc"}, {"region\\r.0.0.mca": "ABC"}, "mismatch") + for actual in ({}, {"region/r.0.0.mca": "DEF"}, {"region/r.0.0.mca": "ABC", "extra": "ABC"}): + with self.assertRaisesRegex(AssertionError, "mismatch"): + native.require_equal_hashes(actual, {"region/r.0.0.mca": "ABC"}, "mismatch") + + def test_wrong_runtime_and_errors_fail_native_boot_check(self): + log = self.root / "console.log" + good = ("Starting minecraft server version 1.15.2\n" + "Forge mod loading, version 31.2.57, for MC 1.15.2 with MCP 20200515.085601\nDone\n") + log.write_text(good) + native.require_native_boot(log) + notice = "[Thread-1/FATAL] [ne.mi.co.ForgeConfig/CORE]: Forge config just got changed on the file system!\n" + log.write_text(good + notice) + native.require_native_boot(log) + for text in (good.replace("1.15.2", "1.13.2"), good + "[Server thread/ERROR] failure\n", + good + "[Server thread/FATAL] failure\n", good + notice.replace("ForgeConfig/CORE", "Other/CORE")): + log.write_text(text) + with self.assertRaises(AssertionError): + native.require_native_boot(log) + + def test_staged_extra_or_changed_jars_fail(self): + runtime = self.root / "runtime" + (runtime / "mods").mkdir(parents=True) + seed = self.root / "seed" + seed.mkdir() + (seed / native.LAUNCHER).write_bytes(b"launcher") + (runtime / native.LAUNCHER).write_bytes(b"launcher") + jar = self.root / "candidate.jar" + jar.write_bytes(b"candidate") + (runtime / "mods" / jar.name).write_bytes(jar.read_bytes()) + write_json(runtime / "input-jars.json", [{"file": jar.name, "sha256": digest(jar)}]) + profile = (seed, native.LAUNCHER, [jar]) + native.verify_staged(runtime, profile) + (runtime / "mods" / "extra.jar").write_bytes(b"unexpected") + with self.assertRaisesRegex(AssertionError, "jar set changed"): + native.verify_staged(runtime, profile) + + def test_failures_are_retained_both_profiles_attempted_and_source_rechecked(self): + args = self.source_args() + source_profile = native.pinned_profile(args.source_manifest, args.source_key) + native_profile = (self.root / "native-seed", native.LAUNCHER, []) + native_profile[0].mkdir() + before = fingerprint(args.source) + + def profile(path, key): + return native_profile if key == "target115" else source_profile + + with patch.object(native, "pinned_profile", side_effect=profile), \ + patch.object(native.socket, "socket"), \ + patch.object(native, "prepare", side_effect=RuntimeError("deliberate unit-test failure")) as prepare: + self.assertEqual(native.run(args), 1) + result = native.read_json(args.output / "native115-lifecycle-results.json") + self.assertEqual(prepare.call_count, 2) + self.assertEqual(result["status"], "FAIL") + self.assertTrue(result["source_fixture_unchanged"]) + self.assertTrue(result["helpers_unchanged"]) + self.assertEqual(result["sourceRuntimeBefore"], result["sourceRuntimeAfter"]) + self.assertEqual(fingerprint(args.source), before) + for row in result["profiles"].values(): + self.assertIn("deliberate unit-test failure", row["failure"]) + + +class NativeReadinessTests(unittest.TestCase): + def setUp(self): + root = RIG / "build/port-evidence" + root.mkdir(parents=True, exist_ok=True) + self.temp = tempfile.TemporaryDirectory(dir=root) + self.root = Path(self.temp.name) + self.definition = [dict(id="drill", min=[0, 0, 0], max=[15, 100, 15], + witnesses=[[1, 80, 1]], to=[1, 80, 1], block="steamadvantage:steam_drill")] + + def tearDown(self): + self.temp.cleanup() + + def snapshot(self, tick=100, loaded=True, loads=1, nbt="{}"): + return dict(worldTime=tick, chunks=[dict(pos=[0, 0], loaded=loaded, loads=loads, unloads=0)], + chunkEvents=[dict(operation="load", pos=[0, 0], worldTime=99)] if loads else [], + cases={"drill": [dict(pos=[1, 80, 1], loaded=loaded, nbt=nbt)]}) + + def runner(self, snapshots): + runner = native.NativeLifecycleRun(self.root, None, None, self.definition) + pending = iter(snapshots) + + def probe(operation, label): + value = copy.deepcopy(next(pending)) + path = self.root / "advantage-works-results" / f"lifecycle-{label}.json" + self.assertFalse(path.exists()) + write_json(path, value) + return value + + runner._raw_probe = Mock(side_effect=probe) + return runner + + def test_readiness_does_not_inspect_machine_nbt_block_or_progress(self): + first, ready = self.snapshot(), self.snapshot(101, nbt="not even parsed") + runner = self.runner([ready]) + with patch.object(native.time, "sleep"): + selected = runner._wait_ready("test", first) + self.assertEqual(selected, ready) + self.assertEqual(runner.observations[-1]["first_ready"]["world_time"], 101) + self.assertEqual(native.loaded_issues(ready, self.definition), []) + + def unloaded(self, tick=101, callbacks=0, events=False): + value = self.snapshot(tick, loaded=False) + value["chunks"][0]["unloads"] = callbacks + if events: + value["chunkEvents"].append(dict(operation="unload", pos=[0, 0], worldTime=tick)) + return value + + def unload_runner(self, snapshots, deadline=45): + runner = self.runner(snapshots) + runner._unload_context = dict(name="cycle", before=self.snapshot(), deadline=deadline) + return runner + + def test_absent_holder_waits_for_queued_real_unload_callback(self): + pending, complete = self.unloaded(), self.unloaded(102, callbacks=1, events=True) + runner = self.unload_runner([pending, complete]) + with patch.object(native.time, "sleep"), patch.object(native.time, "monotonic", return_value=0): + selected = runner.probe("snapshot", "cycle-absent-00") + self.assertEqual(selected, complete) + self.assertEqual(runner._raw_probe.call_count, 2) + lifecycle.require_unloaded(self.snapshot(), selected) + self.assertEqual(native.read_json(self.root / "advantage-works-results/lifecycle-cycle-absent-00.json"), complete) + self.assertTrue((self.root / "advantage-works-results/lifecycle-cycle-absent-00-native-initial.json").exists()) + + def test_queued_callback_wait_never_extends_original_unload_budget(self): + pending = self.unloaded() + runner = self.unload_runner([pending], deadline=1) + with patch.object(native.time, "sleep") as sleep, patch.object(native.time, "monotonic", return_value=1): + selected = runner.probe("snapshot", "cycle-absent-44") + self.assertEqual(selected, pending) + sleep.assert_not_called() + with self.assertRaisesRegex(AssertionError, "No real unload event"): + lifecycle.require_unloaded(self.snapshot(), selected) + + def test_still_loaded_snapshot_returns_to_inherited_polling(self): + loaded = self.snapshot(101) + runner = self.unload_runner([loaded]) + with patch.object(native.time, "sleep") as sleep: + selected = runner.probe("snapshot", "cycle-absent-00") + self.assertEqual(selected, loaded) + sleep.assert_not_called() + + def test_unload_counter_without_an_actual_event_is_not_ready(self): + pending = self.unloaded(callbacks=1) + runner = self.unload_runner([pending], deadline=0) + with patch.object(native.time, "monotonic", return_value=0): + runner.probe("snapshot", "cycle-absent-00") + self.assertEqual(runner.observations[-1]["status"], "PENDING") + self.assertTrue(native.unload_event_issues(self.snapshot(), pending)) + + def test_unload_context_cannot_leak_to_another_cycle_after_failure(self): + runner = self.runner([]) + with patch.object(lifecycle.LifecycleRun, "unload_reload", side_effect=RuntimeError("probe failure")): + with self.assertRaisesRegex(RuntimeError, "probe failure"): + runner.unload_reload("cycle") + self.assertIsNone(runner._unload_context) + + def test_unload_wrapper_delegates_the_original_protocol_exactly_once(self): + runner = self.runner([]) + result = object() + + def original(instance, name): + self.assertIs(instance, runner) + self.assertEqual(name, "cycle") + self.assertEqual(instance._unload_context, dict(name="cycle", before=None, deadline=None)) + return result + + with patch.object(lifecycle.LifecycleRun, "unload_reload", autospec=True, side_effect=original) as call: + self.assertIs(runner.unload_reload("cycle"), result) + call.assert_called_once_with(runner, "cycle") + self.assertIsNone(runner._unload_context) + + def test_unload_budget_is_anchored_to_release_and_not_extended_by_samples(self): + runner = self.unload_runner([self.unloaded(), self.unloaded()]) + with patch.object(native.time, "monotonic", return_value=10): + runner.probe("release", "cycle-released") + self.assertEqual(runner._unload_context["deadline"], 55) + with patch.object(native.time, "monotonic", return_value=55): + runner.probe("snapshot", "cycle-absent-44") + self.assertEqual(runner._unload_context["deadline"], 55) + + def test_reload_waits_for_genuine_load_then_first_tick_without_reading_values(self): + absent = self.unloaded(callbacks=1, events=True) + initial, ready = self.snapshot(102, loads=2), self.snapshot(103, loads=2, nbt="{progress:0s}") + runner = self.unload_runner([initial, ready]) + runner._unload_context["absent"] = absent + with patch.object(native.time, "sleep"): + selected = runner.probe("load", "cycle-loaded") + self.assertEqual(selected, ready) + self.assertEqual(runner._raw_probe.call_args_list[0].args[0], "load") + self.assertEqual(native.read_json(self.root / "advantage-works-results/lifecycle-cycle-loaded.json"), ready) + + def test_wait_uses_first_real_tick_even_when_desired_values_arrive_later(self): + before = self.snapshot(nbt="{progress:12s}") + immediate = self.snapshot(nbt="{progress:12s}") + same_tick = self.snapshot(nbt="{progress:12s}") + first_tick = self.snapshot(101, nbt="{progress:0s}") + later_pass = self.snapshot(102, nbt="{progress:13s}") + runner = self.runner([before, immediate, same_tick, first_tick, later_pass]) + with patch.object(native.time, "sleep"), \ + patch.object(lifecycle, "progress_results", side_effect=AssertionError("oracle inside wait")), \ + patch.object(lifecycle, "changed_target_results", side_effect=AssertionError("oracle inside wait")): + selected = runner.probe("legacy-read", "legacy") + self.assertEqual(selected, first_tick) + self.assertEqual(runner._raw_probe.call_count, 4) + canonical = native.read_json(self.root / "advantage-works-results/lifecycle-legacy.json") + self.assertEqual(canonical, first_tick) + self.assertNotEqual(canonical, later_pass) + record = runner.observations[-1] + source = self.root / "advantage-works-results" / ("lifecycle-" + record["selected_label"] + ".json") + self.assertEqual(source.read_bytes(), (self.root / "advantage-works-results/lifecycle-legacy.json").read_bytes()) + self.assertEqual([sample["world_time"] for sample in record["samples"]], [100, 100, 101]) + + def test_changed_read_is_not_reissued_or_retried_until_target_clears(self): + before, immediate = self.snapshot(nbt="{progress:12s}"), self.snapshot(nbt="{progress:12s}") + retargeted = self.snapshot(101, nbt="{progress:1s,targetX:2,targetY:80,targetZ:1}") + runner = self.runner([before, immediate, retargeted, self.snapshot(102, nbt="{progress:0s}")]) + with patch.object(native.time, "sleep"): + self.assertEqual(runner.probe("changed-target-read", "changed"), retargeted) + self.assertEqual([call.args[0] for call in runner._raw_probe.call_args_list], + ["snapshot", "changed-target-read", "snapshot"]) + + def guard_samples(self, changed_items="[]"): + old = "{progress:12s,targetX:2,targetY:80,targetZ:1,Items:[]}" + advanced = old.replace("12s", "13s") + cleared = "{progress:0s,Items:" + changed_items + "}" + return [self.snapshot(nbt=old), self.snapshot(nbt=old), self.snapshot(nbt=old), + self.snapshot(101, nbt=advanced), self.snapshot(101, nbt=advanced), + self.snapshot(101, nbt=cleared), self.snapshot(102, nbt=cleared)] + + def test_inherited_guard_calls_each_unchanged_oracle_once_after_selection(self): + runner = self.runner(self.guard_samples()) + with patch.object(native.time, "sleep"), \ + patch.object(lifecycle, "progress_results", wraps=lifecycle.progress_results) as progress, \ + patch.object(lifecycle, "changed_target_results", wraps=lifecycle.changed_target_results) as changed: + results = runner.save_read_guards() + self.assertEqual(progress.call_count, 1) + self.assertEqual(changed.call_count, 1) + self.assertEqual(results["legacy_read"]["drill"]["status"], "PASS") + self.assertEqual(results["changed_target_read"]["drill"]["status"], "PASS") + + def test_inherited_unchanged_inventory_guard_still_fails(self): + runner = self.runner(self.guard_samples('[{id:"minecraft:dirt",Count:1b}]')) + with patch.object(native.time, "sleep"): + with self.assertRaisesRegex(AssertionError, "changed drill inventory"): + runner.save_read_guards() + + def test_loaded_chunks_need_actual_load_events_and_counter_increase(self): + before = self.snapshot(loaded=False, loads=1) + loaded = self.snapshot(101, loads=1) + self.assertTrue(native.load_event_issues(before, loaded)) + loaded["chunks"][0]["loads"] = 2 + self.assertEqual(native.load_event_issues(before, loaded), []) + loaded["chunkEvents"] = [] + self.assertTrue(native.load_event_issues(before, loaded)) + + def test_tick_gate_starts_only_once_all_witnesses_are_loaded(self): + first = self.snapshot(loaded=False, loads=0) + loaded, ready = self.snapshot(101), self.snapshot(102) + runner = self.runner([loaded, ready]) + with patch.object(native.time, "sleep"): + self.assertEqual(runner._wait_ready("test", first, reload_before=first), ready) + samples = runner.observations[-1]["samples"] + self.assertIsNone(samples[0]["loaded_since_world_time"]) + self.assertEqual(samples[1]["loaded_since_world_time"], 101) + + def test_missing_witness_and_missing_chunk_are_never_ready(self): + for key in ("cases", "chunks"): + value = self.snapshot() + value[key] = {} if key == "cases" else [] + self.assertTrue(native.loaded_issues(value, self.definition)) + + def test_stalled_native_time_fails_bounded_without_synthetic_advancement(self): + runner = self.runner([self.snapshot()] * 4) + with patch.object(native, "READINESS_SAMPLES", 3), patch.object(native.time, "sleep"): + with self.assertRaisesRegex(TimeoutError, "actual ticks"): + runner._wait_ready("test", self.snapshot()) + self.assertEqual(runner.observations[-1]["status"], "FAIL") + self.assertTrue(all(row["world_time"] == 100 for row in runner.observations[-1]["samples"])) + + def test_wall_guard_cannot_be_passed_by_late_tick(self): + runner = self.runner([self.snapshot(101)]) + with patch.object(native.time, "monotonic", side_effect=[0, 0, 21]), patch.object(native.time, "sleep"): + with self.assertRaises(TimeoutError): + runner._wait_ready("test", self.snapshot()) + + def test_native_restart_only_restores_tickets_not_fixtures(self): + absent, loaded, ready = self.snapshot(loaded=False, loads=0), self.snapshot(), self.snapshot(101) + runner = self.runner([absent, loaded, ready]) + with patch.object(lifecycle.LifecycleRun, "boot") as boot, patch.object(native, "require_native_boot"), \ + patch.object(native.time, "sleep"): + runner.boot("midblock-restart") + boot.assert_called_once_with("midblock-restart") + self.assertEqual([call.args[0] for call in runner._raw_probe.call_args_list], ["snapshot", "load", "snapshot"]) + self.assertEqual(runner.observations[-1]["reload_before_label"], "midblock-restart-native-unrestored") + + def test_initial_and_frozen_boot_never_rebuild_or_load_lifecycle_fixture(self): + runner = self.runner([]) + with patch.object(lifecycle.LifecycleRun, "boot"): + runner.boot("lifecycle") + runner.boot("retained13-import") + runner._raw_probe.assert_not_called() + + def test_raw_snapshot_overwrite_rejected(self): + runner = native.NativeLifecycleRun(self.root, None, None, self.definition) + write_json(self.root / "advantage-works-results/lifecycle-original.json", self.snapshot()) + with patch.object(lifecycle.LifecycleRun, "probe") as original: + with self.assertRaises(FileExistsError): + runner._raw_probe("snapshot", "original") + original.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_oil.py b/test-rig/migration/test_native115_oil.py new file mode 100644 index 0000000..7474f53 --- /dev/null +++ b/test-rig/migration/test_native115_oil.py @@ -0,0 +1,440 @@ +"""Focused native oil safety, protocol and unchanged-oracle negative controls.""" + +from copy import deepcopy +import json +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import zipfile + +import lifecycle +from matrix import WORLD, digest, fingerprint, write_json +from native115 import LAUNCHER, RIG +import native115_oil as native +import oil_transfer + + +BOOT_PROOF = ("Starting minecraft server version 1.15.2\n" + "Forge mod loading, version 31.2.57, for MC 1.15.2 with MCP 20200515.085601\n") + + +def snapshot(definition, amount, identity=0): + cases = {} + for index, lane in enumerate(definition): + cases[lane["id"]] = [ + dict(pos=lane["source"], block="minecraft:air"), + dict(pos=lane["drain"], block="poweradvantage:fluid_drain", tileIdentity=identity + index * 2 + 1, + nbt="{Tank:{Empty:1b}}"), + *[dict(pos=pos, block="poweradvantage:fluid_pipe") for pos in lane["pipes"]], + dict(pos=lane["output"], block=lane["tank"], tileIdentity=identity + index * 2 + 2, + nbt=('{Tank:{Amount:' + str(amount) + ',FluidName:"' + lane["fluid"] + '"}}') + if amount else "{Tank:{Empty:1b}}")] + return dict(cases=cases) + + +class NativeOilTests(unittest.TestCase): + def test_mineralogy_pin_matches_every_native115_profile(self): + seen = 0 + for path in (RIG / "profiles/native115").glob("*.json"): + for mod in json.loads(path.read_text(encoding="utf-8"))["mods"]: + if mod["id"] == "mineralogy": + self.assertEqual(native.MINERALOGY_VERSION, mod["version"], path.name) + self.assertEqual(native.MINERALOGY_SHA256, mod["sha256"], path.name) + seen += 1 + self.assertGreater(seen, 0) + + def setUp(self): + root = RIG / "build/port-evidence" + root.mkdir(parents=True, exist_ok=True) + self.temp = tempfile.TemporaryDirectory(dir=root) + self.root = Path(self.temp.name) + + def tearDown(self): + self.temp.cleanup() + + def profile(self): + seed = self.root / "installed-seed" + (seed / "libraries/subdir").mkdir(parents=True) + (seed / "libraries/subdir/runtime.jar").write_bytes(b"unit-test-library-not-executed") + (seed / LAUNCHER).write_bytes(b"unit-test-launcher-not-executed") + (seed / "minecraft_server.1.15.2.jar").write_bytes(b"unit-test-vanilla-not-executed") + (seed / WORLD).mkdir() + (seed / WORLD / "level.dat").write_bytes(b"protected-unit-test-world-never-copied") + (seed / "mods").mkdir() + (seed / "mods/unpinned.jar").write_bytes(b"never-copy-seed-mods") + (seed / "server.properties").write_text("protected-seed-settings\n") + jars = [] + for mod in ("poweradvantage", "advantageworkstest", "orespawn"): + jar = self.root / (mod + "-1.0.0.115021.jar") + with zipfile.ZipFile(jar, "w") as archive: + archive.writestr("META-INF/mods.toml", '[[mods]]\nmodId="' + mod + '"\nversion="1.0.0.115021"\n') + jars.append(dict(path=str(jar), sha256=digest(jar), id=mod, version="1.0.0.115021")) + pins = dict(seed=str(seed), launcher=LAUNCHER, launcher_sha256=digest(seed / LAUNCHER), jars=jars) + manifest = self.root / "target.json" + write_json(manifest, dict(target115=pins)) + java = self.root / "unit-java-not-executed.exe" + java.write_bytes(b"unit test executable placeholder, process factory is mocked") + args = SimpleNamespace(target_manifest=manifest, output=self.root / "native115-oil-unit", + java8=java, port=25576, seconds=2) + return args, (seed, LAUNCHER, [Path(jar["path"]) for jar in jars]), pins + + def server_factory(self, mutate=None, transform=None, failure=None, shutdown=False): + owner = self + self.processes = [] + self.commands = [] + + class ProtocolServer: + """Unit protocol double; no Java or Minecraft process is started.""" + def __init__(self, runtime, java, launcher, label): + self.runtime, self.label = runtime, label + self.closed = False + self.identity = len(owner.processes) * 100 + owner.processes.append(self) + self.definition = native.read_json(runtime / "lifecycle-fixture.json") + (runtime / f"console-{label}.log").write_text(BOOT_PROOF) + + def send(self, command): + owner.commands.append((self.label, command)) + if mutate: + mutate(self, command) + if not command.startswith("advlifecycle "): + return + _, operation, label = command.split() + if label == failure: + raise AssertionError("unit-injected probe failure") + amount = {"oil-empty": 0, "oil-first-fill": 1000, "oil-restored": 1000, + "oil-refilled": 2000, "oil-retained": 2000}.get(label, 0) + value = snapshot(self.definition, amount, self.identity) + if transform: + transform(label, value) + write_json(self.runtime / "advantage-works-results" / f"lifecycle-{label}.json", value) + + def wait(self, token, timeout): + pass + + def close(self): + self.closed = True + if shutdown: + raise AssertionError("unit-injected shutdown failure") + + return ProtocolServer + + def report(self, args): + return native.read_json(args.output / "native115-oil-results.json") + + def test_power_definitions_are_exact_unchanged_copies(self): + original = oil_transfer.fixtures() + lanes = native.fixtures(dict(jars=[])) + self.assertEqual(lanes, original) + lanes[0]["source"][0] += 1 + self.assertEqual(oil_transfer.fixtures(), original) + + def test_optional_mineralogy_has_two_distinct_native_lanes_without_mutating_power(self): + pins = dict(jars=[dict(id="mineralogy", version=native.MINERALOGY_VERSION, sha256=native.MINERALOGY_SHA256)]) + lanes = native.fixtures(pins) + self.assertEqual(lanes[:4], oil_transfer.fixtures()) + self.assertEqual(len(lanes), 6) + for lane, template in zip(lanes[4:], lanes[:2]): + self.assertEqual(lane["fluid"], "mineralogy:crude_oil") + self.assertEqual(lane["bucket"], "mineralogy:crude_oil_bucket") + self.assertEqual(lane["tank"], template["tank"]) + for point, old in zip(lane["witnesses"], template["witnesses"]): + self.assertEqual(point, [old[0] + 256, *old[1:]]) + native.require_document(snapshot(lanes, 1000), lanes) + self.assertEqual(len(oil_transfer.inspect(snapshot(lanes, 1000), lanes, 1000)), 6) + + def test_mineralogy_wrong_version_hash_or_duplicate_is_not_silently_skipped(self): + good = dict(id="mineralogy", version=native.MINERALOGY_VERSION, sha256=native.MINERALOGY_SHA256) + for jars in ([dict(good, version="6.0.0.113021")], [dict(good, sha256="0" * 64)], [good, good]): + with self.subTest(jars=jars), self.assertRaises(ValueError): + native.fixtures(dict(jars=jars)) + + def test_bounds_headroom_connectivity_overlap_and_duplicate_ids_are_strict(self): + def moved(lanes): + lanes[0]["source"][0] += 1 + mutations = [lambda lanes: lanes[0]["min"].__setitem__(0, 0), + lambda lanes: lanes[0]["max"].__setitem__(0, lanes[0]["min"][0] + 32), + lambda lanes: lanes[0]["source"].__setitem__(1, lanes[0]["max"][1]), + moved, lambda lanes: lanes.append(deepcopy(lanes[0]))] + for mutate in mutations: + lanes = oil_transfer.fixtures() + mutate(lanes) + with self.assertRaises(AssertionError): + native.require_bounds(lanes) + + def test_missing_extra_duplicate_and_moved_snapshot_witnesses_fail(self): + lanes = oil_transfer.fixtures() + for mutate in (lambda s: s["cases"].pop(lanes[0]["id"]), + lambda s: s["cases"].update(extra=[]), + lambda s: s["cases"][lanes[0]["id"]].pop(), + lambda s: s["cases"][lanes[0]["id"]].append(s["cases"][lanes[0]["id"]][0]), + lambda s: s["cases"][lanes[0]["id"]][0].update(pos=[0, 80, 0])): + document = snapshot(lanes, 1000) + mutate(document) + with self.assertRaises(AssertionError): + native.require_document(document, lanes) + + def test_existing_quantity_fluid_and_tile_identity_oracles_remain_strict(self): + lanes = native.fixtures(dict(jars=[])) + for amount in (0, 999, 1001, 2000): + with self.assertRaises(AssertionError): + oil_transfer.inspect(snapshot(lanes, amount), lanes, 1000) + wrong = snapshot(lanes, 1000) + wrong["cases"][lanes[0]["id"]][-1]["nbt"] = '{Tank:{Amount:1000,FluidName:"minecraft:water"}}' + with self.assertRaises(AssertionError): + oil_transfer.inspect(wrong, lanes, 1000) + with self.assertRaises(AssertionError): + oil_transfer.require_same_machines(snapshot(lanes, 1000), snapshot(lanes, 2000, 100), lanes) + + def test_native_log_requires_both_versions_and_rejects_runtime_command_and_probe_errors(self): + log = self.root / "console.log" + log.write_text(BOOT_PROOF + "[watcher/FATAL] " + native.CONFIG_NOTICE + "\n") + native.require_native_boot(log) + for text in (BOOT_PROOF.replace("1.15.2", "1.13.2"), BOOT_PROOF.replace("31.2.57", "25.0.223"), + *[BOOT_PROOF + failure + "\n" for failure in ( + "[main/ERROR] bad", "[main/FATAL] actual fatal", "NoSuchFieldError: bad", + "NoSuchMethodError: bad", "Failed to reload data packs", "Lifecycle probe failed", + "ADVLIFECYCLE FAIL", "Unknown or incomplete command", + "Incorrect argument for command", "An unexpected error occurred trying to execute that command")]): + log.write_text(text) + with self.subTest(text=text), self.assertRaises(AssertionError): + native.require_native_boot(log) + + def test_prepare_copies_pinned_libraries_only_never_seed_world_or_settings(self): + args, profile, pins = self.profile() + before = fingerprint(profile[0]) + runtime = self.root / "target" + definition = native.fixtures(pins) + self.assertEqual(native.prepare(runtime, profile, definition, args.port, pins, before), LAUNCHER) + self.assertFalse((runtime / WORLD).exists()) + self.assertFalse((runtime / "mods/unpinned.jar").exists()) + self.assertIn("server-port=25576", (runtime / "server.properties").read_text()) + self.assertNotIn("3;minecraft:bedrock", (runtime / "server.properties").read_text()) + self.assertEqual(fingerprint(profile[0]), before) + self.assertEqual(native.read_json(runtime / "lifecycle-fixture.json"), definition) + + def test_staged_jars_cannot_follow_a_changed_original_instead_of_captured_pins(self): + args, profile, pins = self.profile() + before = fingerprint(profile[0]) + runtime = self.root / "target" + native.prepare(runtime, profile, native.fixtures(pins), args.port, pins, before) + jar = profile[2][0] + jar.write_bytes(b"changed after pin") + (runtime / "mods" / jar.name).write_bytes(jar.read_bytes()) + write_json(runtime / "input-jars.json", [dict(file=p.name, sha256=digest(p)) for p in profile[2]]) + with self.assertRaises(AssertionError): + native.verify_staged(runtime, pins, before) + + def test_staged_launcher_and_library_tampering_fail(self): + args, profile, pins = self.profile() + before = fingerprint(profile[0]) + runtime = self.root / "target" + native.prepare(runtime, profile, native.fixtures(pins), args.port, pins, before) + for path in (runtime / LAUNCHER, runtime / "libraries/subdir/runtime.jar"): + old = path.read_bytes() + path.write_bytes(b"changed") + with self.assertRaises(AssertionError): + native.verify_staged(runtime, pins, before) + path.write_bytes(old) + + def test_only_ordered_initial_construction_is_allowed_and_never_after_baseline(self): + args, profile, pins = self.profile() + lanes = native.fixtures(pins) + runner = native.NativeOilRun(self.root, args.java8, LAUNCHER, lanes, pins, {}) + runner.boots = [native.BOOT_LABELS[0]] + runner.server = SimpleNamespace(send=lambda command: None) + for command in ("advlifecycle offer illicit", "data merge block 11272 80 2056 {}", + "setblock 11272 81 2056 poweradvantage:crude_oil", + "advworks reset all", "advlifecycle snapshot ok\nstop"): + with self.subTest(command=command), self.assertRaises(AssertionError): + runner.command(command) + with self.assertRaises(AssertionError): + runner.seal_construction() + initial = native.construction_commands(lanes) + for command in initial: + runner.command(command) + runner.seal_construction() + for command in initial: + with self.assertRaises(AssertionError): + runner.command(command) + + def test_buckets_require_empty_or_restored_gate_and_at_most_two_placements(self): + args, profile, pins = self.profile() + runner = native.NativeOilRun(self.root, args.java8, LAUNCHER, native.fixtures(pins), pins, {}) + runner.server = SimpleNamespace(send=lambda command: None) + runner.boots = [native.BOOT_LABELS[0]] + with self.assertRaises(AssertionError): + runner.command("advlifecycle place-buckets too-soon") + with self.assertRaises(AssertionError): + runner.permit_buckets() + runner.pending_construction = [] + runner.seal_construction() + for index in range(2): + runner.boots = list(native.BOOT_LABELS[:index + 1]) + runner.permit_buckets() + runner.command("advlifecycle place-buckets permitted") + with self.assertRaises(AssertionError): + runner.command("advlifecycle place-buckets duplicate") + runner.boots = list(native.BOOT_LABELS) + with self.assertRaises(AssertionError): + runner.permit_buckets() + + def test_complete_protocol_three_boots_two_source_only_placements_and_all_evidence(self): + args, profile, pins = self.profile() + before = fingerprint(profile[0]) + with patch.object(lifecycle, "Server", self.server_factory()), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 0) + report = self.report(args) + self.assertEqual(report["status"], "PASS") + self.assertEqual(report["full_process_restarts"], 2) + self.assertEqual(report["bucket_placements"], 2) + self.assertEqual([phase["phase"] for phase in report["phases"]], ["first-fill", "restored", "refilled", "retained"]) + self.assertEqual([p["cases"][0]["total_mB"] for p in report["phases"]], [1000, 1000, 2000, 2000]) + self.assertEqual(report["laneCount"], 4) + self.assertEqual(report["seedFingerprintBefore"], before) + self.assertEqual(report["seedFingerprintAfter"], before) + self.assertTrue(report["helpers_unchanged"] and report["seed_unchanged"] and report["manifest_unchanged"]) + self.assertEqual([p.label for p in self.processes], list(native.BOOT_LABELS)) + self.assertTrue(all(p.closed for p in self.processes)) + later = [command for label, command in self.commands if label != native.BOOT_LABELS[0]] + self.assertFalse(any(command.startswith(("fill ", "setblock ", "data ")) for command in later)) + self.assertEqual(sum("place-buckets" in command for command in later), 1) + self.assertEqual((args.output / "target-manifest.json").read_bytes(), args.target_manifest.read_bytes()) + + def test_probe_failure_is_retained_with_seed_fingerprints_and_process_closed(self): + args, profile, _ = self.profile() + before = fingerprint(profile[0]) + with patch.object(lifecycle, "Server", self.server_factory(failure="oil-refilled")), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 1) + report = self.report(args) + self.assertEqual(report["status"], "FAIL") + self.assertIn("probe failure", report["failure"]) + self.assertEqual(report["seedFingerprintAfter"], before) + self.assertTrue(all(p.closed for p in self.processes)) + + def test_shutdown_failure_cannot_be_success(self): + args, _, _ = self.profile() + with patch.object(lifecycle, "Server", self.server_factory(shutdown=True)), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 1) + self.assertIn("shutdown failure", self.report(args)["failure"]) + + def test_restart_quantity_loss_blocks_refill_not_repaired_by_new_sources(self): + args, _, _ = self.profile() + def transform(label, value): + if label == "oil-restored": + lane = next(iter(value["cases"].values())) + lane[-1]["nbt"] = lane[-1]["nbt"].replace("Amount:1000", "Amount:999") + with patch.object(lifecycle, "Server", self.server_factory(transform=transform)), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 1) + self.assertIn("Lost/duplicated oil", self.report(args)["failure"]) + self.assertEqual(sum("place-buckets" in command for _, command in self.commands), 1) + self.assertEqual(len(self.processes), 2) + + def test_nonempty_baseline_cannot_place_any_bucket(self): + args, _, _ = self.profile() + def transform(label, value): + if label == "oil-empty": + next(iter(value["cases"].values()))[-1]["nbt"] = '{Tank:{Amount:1,FluidName:"poweradvantage:crude_oil"}}' + with patch.object(lifecycle, "Server", self.server_factory(transform=transform)), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 1) + self.assertIn("Fixture was not empty", self.report(args)["failure"]) + self.assertFalse(any("place-buckets" in command for _, command in self.commands)) + + def test_refill_cannot_replace_a_machine_even_with_exact_amount(self): + args, _, _ = self.profile() + def transform(label, value): + if label == "oil-refilled": + next(iter(value["cases"].values()))[-1]["tileIdentity"] += 1 + with patch.object(lifecycle, "Server", self.server_factory(transform=transform)), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 1) + self.assertIn("Refill replaced a machine", self.report(args)["failure"]) + self.assertEqual(len(self.processes), 2) + + def test_missing_initial_identity_cannot_qualify(self): + args, _, _ = self.profile() + def transform(label, value): + if label == "oil-empty": + next(iter(value["cases"].values()))[1].pop("tileIdentity") + with patch.object(lifecycle, "Server", self.server_factory(transform=transform)), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 1) + self.assertFalse(any("place-buckets" in command for _, command in self.commands)) + + def test_staging_failure_still_retains_full_seed_fingerprints(self): + args, profile, _ = self.profile() + before = fingerprint(profile[0]) + with patch.object(native, "prepare", side_effect=AssertionError("unit-injected staging failure")): + self.assertEqual(native.run(args), 1) + report = self.report(args) + self.assertIn("staging failure", report["failure"]) + self.assertEqual(report["seedFingerprintBefore"], before) + self.assertEqual(report["seedFingerprintAfter"], before) + + def test_wrong_launcher_and_out_of_order_boots_are_rejected_before_process(self): + args, _, pins = self.profile() + runner = native.NativeOilRun(self.root, args.java8, "forge-1.13.2-25.0.223.jar", native.fixtures(pins), pins, {}) + with self.assertRaises(AssertionError): + runner.boot(native.BOOT_LABELS[0]) + runner.launcher = LAUNCHER + for label in (native.BOOT_LABELS[1], "invented-boot"): + with self.assertRaises(AssertionError): + runner.boot(label) + + def test_late_log_errors_are_audited_after_stop(self): + args, _, _ = self.profile() + def mutate(server, command): + if command == "save-all flush": + with (server.runtime / f"console-{server.label}.log").open("a") as stream: + stream.write("[main/ERROR] unit-injected late failure\n") + with patch.object(lifecycle, "Server", self.server_factory(mutate=mutate)), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 1) + self.assertIn("runtime_audit_failure", self.report(args)) + + def test_full_seed_fingerprint_catches_changes_outside_world(self): + args, profile, _ = self.profile() + def mutate(server, command): + (profile[0] / "server.properties").write_text("unit-injected concurrent seed change") + with patch.object(lifecycle, "Server", self.server_factory(mutate=mutate)), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 1) + report = self.report(args) + self.assertFalse(report["seed_unchanged"]) + self.assertIn("server.properties", report["seedFingerprintAfter"]) + + def test_mutable_manifest_is_detected_and_captured_manifest_retained(self): + args, _, _ = self.profile() + original = args.target_manifest.read_bytes() + def mutate(server, command): + args.target_manifest.write_bytes(original + b"\n") + with patch.object(lifecycle, "Server", self.server_factory(mutate=mutate)), patch.object(native.time, "sleep"): + self.assertEqual(native.run(args), 1) + self.assertFalse(self.report(args)["manifest_unchanged"]) + self.assertEqual((args.output / "target-manifest.json").read_bytes(), original) + + def test_stale_input_manifest_fails_before_staging_or_process(self): + args, profile, _ = self.profile() + profile[2][0].write_bytes(b"not the pinned jar") + with patch.object(native, "prepare") as prepare, self.assertRaises(AssertionError): + native.run(args) + prepare.assert_not_called() + self.assertFalse(args.output.exists()) + + def test_output_cannot_overlap_seed_manifest_or_leave_scoped_build(self): + args, profile, _ = self.profile() + for output in (profile[0], profile[0] / "native115-oil-unsafe", args.target_manifest, + RIG / "migration/native115-oil-unsafe", self.root / "wrong-prefix"): + args.output = output + with self.subTest(output=output), self.assertRaises((ValueError, FileExistsError)): + native.run(args) + + def test_invalid_port_or_interval_fails_before_staging(self): + args, _, _ = self.profile() + for port, seconds in ((0, 8), (65536, 8), (25576, 1), (25576, 121)): + args.port, args.seconds = port, seconds + with self.assertRaises(ValueError): + native.run(args) + self.assertFalse(args.output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_partial.py b/test-rig/migration/test_native115_partial.py new file mode 100644 index 0000000..744c3a0 --- /dev/null +++ b/test-rig/migration/test_native115_partial.py @@ -0,0 +1,114 @@ +"""Native transport controls do not change historical fixture/oracle semantics.""" + +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import extended_lifecycle +from native115_partial import (NativeExtendedRun, NativePartialRun, common_parser, selected_chunks, + subset_load_issues, subset_unload_pending) +from native115_lifecycle import NativeLifecycleRun + + +def snapshot(tick, loaded=True, loads=1, unloads=0, events=None): + return dict(worldTime=tick, chunks=[dict(pos=[1, 2], loaded=loaded, loads=loads, unloads=unloads)], + chunkEvents=events if events is not None else [dict(pos=[1, 2], operation="load")], + cases={"case": [dict(pos=[16, 80, 32], loaded=loaded)]}) + + +class NativePartialTest(unittest.TestCase): + def test_assigned_native115_core_port_is_accepted(self): + arguments = ["--source", "source", "--source-manifest", "source.json", + "--source-hashes", "hashes.json", "--target-manifest", "target.json", + "--java8", "java.exe", "--output", "output", "--port", "25580"] + self.assertEqual(common_parser("core").parse_args(arguments).port, 25580) + with patch("sys.stderr"), self.assertRaises(SystemExit): + common_parser("core").parse_args(arguments[:-1] + ["25584"]) + + def test_extended_keeps_historical_operations(self): + self.assertIs(NativeExtendedRun.build, extended_lifecycle.ExtendedRun.build) + self.assertIs(NativeExtendedRun.partial, extended_lifecycle.ExtendedRun.partial) + self.assertIs(NativeExtendedRun.wait_step, extended_lifecycle.ExtendedRun.wait_step) + self.assertIs(NativeExtendedRun.save_read_guards, NativeLifecycleRun.save_read_guards) + + def test_group_selection_does_not_acquire_unrequested_group(self): + definition = [dict(groups=dict(machine=[[1, 2]], supply=[[2, 2]]))] + self.assertEqual(selected_chunks(definition, "machine"), {(1, 2)}) + + def test_load_requires_actual_event_not_loaded_flag_alone(self): + self.assertTrue(subset_load_issues(snapshot(1, False), snapshot(2, events=[]), {(1, 2)})) + + def test_load_requires_increment_after_absence(self): + self.assertTrue(subset_load_issues(snapshot(1, False), snapshot(2), {(1, 2)})) + self.assertFalse(subset_load_issues(snapshot(1, False), snapshot(2, loads=2), {(1, 2)})) + + def test_load_rejects_unloaded_witness(self): + current = snapshot(2, loads=2) + current["cases"]["case"][0]["loaded"] = False + self.assertTrue(subset_load_issues(snapshot(1, False), current, {(1, 2)})) + + def test_unload_requires_counter_and_callback(self): + before = snapshot(1) + current = snapshot(2, loaded=False, unloads=1) + self.assertEqual(subset_unload_pending(before, current, {(1, 2)}), (True, ["(1, 2)"])) + current["chunkEvents"].append(dict(pos=[1, 2], operation="unload")) + self.assertEqual(subset_unload_pending(before, current, {(1, 2)}), (True, [])) + + def test_unload_does_not_accept_callback_without_absence(self): + current = snapshot(2, unloads=1, events=[dict(pos=[1, 2], operation="unload")]) + self.assertEqual(subset_unload_pending(snapshot(1), current, {(1, 2)}), (False, [])) + + def test_unknown_load_and_release_groups_fail_before_commands(self): + runner = NativePartialRun(Path("unused"), None, None, []) + with patch.object(runner, "_raw_probe") as raw: + for operation in ("load-foreign", "release-foreign"): + with self.assertRaises(ValueError): + runner.probe(operation, "unknown") + raw.assert_not_called() + + def test_unload_callback_wait_preserves_actual_raw_event_and_tick(self): + with tempfile.TemporaryDirectory() as temp: + runner = NativePartialRun(Path(temp), None, None, []) + runner._released_groups["machine"] = dict(before=snapshot(1), selected={(1, 2)}, deadline=1000) + pending = snapshot(2, False, unloads=0) + actual = snapshot(3, False, unloads=1, events=[dict(pos=[1, 2], operation="unload")]) + with patch.object(runner, "_raw_probe", side_effect=[pending, actual]) as raw, \ + patch.object(runner, "_canonical") as canonical, patch.object(runner, "_record"), \ + patch("native115_partial.time.monotonic", return_value=100), patch("native115_partial.time.sleep"): + selected = runner._snapshot_ready("snapshot", "absent") + self.assertIs(selected, actual) + self.assertEqual(raw.call_count, 2) + self.assertEqual(selected["worldTime"], 3) + canonical.assert_called_once_with("absent-native-pending-01", "absent") + + def test_load_selects_first_real_tick_even_if_gameplay_wrong(self): + definition = [dict(min=[16, 0, 32], max=[31, 100, 47])] + with tempfile.TemporaryDirectory() as temp: + runner = NativePartialRun(Path(temp), None, None, definition) + rows = [snapshot(1, False, loads=0), snapshot(2, loads=1), snapshot(2, loads=1), snapshot(3, loads=1)] + rows[-1]["cases"]["case"][0]["nbt"] = '{progress:0,WrongGameplay:1}' + with patch.object(runner, "_raw_probe", side_effect=rows) as raw, \ + patch.object(runner, "_canonical") as canonical, patch.object(runner, "_record"), \ + patch("native115_partial.time.sleep"): + selected = runner._load_ready("load", "reload") + self.assertIs(selected, rows[-1]) + self.assertEqual(raw.call_count, 4) + canonical.assert_called_once_with("reload-native-pending-002", "reload") + + def test_canonical_is_exact_raw_bytes_and_never_overwritten(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) / "advantage-works-results" + root.mkdir() + raw = root / "lifecycle-selected.json" + raw.write_bytes(b'{"worldTime":5,"raw":true}\n') + runner = NativePartialRun(Path(temp), None, None, []) + runner._canonical("selected", "canonical") + self.assertEqual(raw.read_bytes(), (root / "lifecycle-canonical.json").read_bytes()) + with self.assertRaises(FileExistsError): + runner._canonical("selected", "canonical") + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_player_layout.py b/test-rig/migration/test_native115_player_layout.py new file mode 100644 index 0000000..65a3716 --- /dev/null +++ b/test-rig/migration/test_native115_player_layout.py @@ -0,0 +1,21 @@ +import unittest + +from native115_player_layout import verdict + + +class NativeLayoutOracleTest(unittest.TestCase): + def test_scope_is_exact_player_data_not_unparented_fixture_turbine_operation(self): + player = {"Inventory": [{"id": "steamadvantage:musket", "Count": 1, "Slot": 35, + "tag": {"Nested": {"Items": [{"id": "electricadvantage:lithium_battery", "Count": 3, "tag": {"energy": 123}}]}}}], + "EnderItems": [{"id": "poweradvantage:crude_oil_bucket", "Count": 1, "Slot": 26}]} + expected = {"file": player, "adapter": player, "entities": {"unparented-turbine": {"id": "electricadvantage:hydroturbine"}}} + self.assertEqual("PASS", verdict(expected, {"file": player, "adapter": player})["status"]) + + def test_extra_nested_item_tag_is_rejected(self): + expected = {"file": {"Inventory": [{"id": "x:y", "Count": 3, "tag": {"energy": 123}}]}, "adapter": {}} + actual = {"file": {"Inventory": [{"id": "x:y", "Count": 3, "tag": {"energy": 123, "extra": "changed"}}]}, "adapter": {}} + self.assertEqual("FAIL", verdict(expected, actual)["status"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_recipes.py b/test-rig/migration/test_native115_recipes.py new file mode 100644 index 0000000..31030b4 --- /dev/null +++ b/test-rig/migration/test_native115_recipes.py @@ -0,0 +1,210 @@ +"""Native recipe runner safety/oracle tests. No Minecraft or protected input writes.""" + +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +import native115_recipes as recipes + + +class NativeRecipeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def packaged(self, counts=None, bad_predicate=False): + jars = [] + for mod, count in (counts or recipes.MOD_COUNTS).items(): + path = self.root / (mod + ".jar") + with zipfile.ZipFile(path, "w") as jar: + for index in range(count): + stem = f"case_{index}" + jar.writestr(f"data/{mod}/recipes/{stem}.json", json.dumps({"type": "minecraft:crafting_shapeless"})) + predicate = {"item": "minecraft:iron_ingot"} + if bad_predicate: + predicate["count"] = {"min": 1} + jar.writestr(f"data/{mod}/advancements/recipes/{stem}.json", json.dumps({"criteria": { + "has_ingredient": {"trigger": "minecraft:inventory_changed", "conditions": {"items": [predicate]}} + }})) + jar.writestr(f"data/{mod}/recipes/crushing_test.json", json.dumps({"type": "basemetals:crushing"})) + jars.append(path) + return jars + + def test_fixture_uses_packaged_advancements_and_preserves_109_inventory(self): + definition, sources = recipes.recipe_fixture(self.packaged()) + self.assertEqual(109, len(definition["recipes"])) + self.assertEqual(109, len(sources)) + self.assertEqual(27, len(definition["materials"])) + self.assertTrue(all(row["ingredients"] == {"has_ingredient": {"item": "minecraft:iron_ingot"}} + for row in definition["recipes"])) + self.assertTrue(all("crushing" not in identity for identity in sources)) + + def test_fixture_inventory_cannot_silently_shrink(self): + counts = dict(recipes.MOD_COUNTS, poweradvantage=34) + with self.assertRaises(AssertionError): + recipes.recipe_fixture(self.packaged(counts)) + + def test_fixture_rejects_unsupported_predicates(self): + with self.assertRaises(AssertionError): + recipes.recipe_fixture(self.packaged(bad_predicate=True)) + + def test_fixture_rejects_duplicate_packaged_recipes(self): + jars = self.packaged() + with self.assertRaises(AssertionError): + recipes.recipe_fixture(jars + jars[:1]) + + def test_unlock_schedule_preserves_modes_and_full_process_persistence(self): + self.assertEqual(5, len(recipes.UNLOCK_BOOTS)) + self.assertEqual(["NORMAL", "NORMAL", "TECH_PROGRESSION", "APOCALYPTIC", "NORMAL"], + [row[0] for row in recipes.UNLOCK_BOOTS]) + self.assertEqual(3, sum("sweep" in row[2] for row in recipes.UNLOCK_BOOTS)) + self.assertEqual(3, sum("persist" in row[2] for row in recipes.UNLOCK_BOOTS)) + self.assertEqual(4, sum("restore" in row[2] for row in recipes.UNLOCK_BOOTS)) + + def unlock_report(self): + definition = {"recipes": [{"id": "poweradvantage:case"}]} + report = {"status": "PASS", "potatoCraftingCases": 59, "bookRecipes": [{"id": "poweradvantage:case"}], + "cases": [{"id": "poweradvantage:case", "recipeActive": True, "status": "PASS"}], + "additionalMaterials": []} + return definition, report + + def test_unlock_requires_every_case_and_all_59_native_potato_checks(self): + definition, report = self.unlock_report() + self.assertEqual(1, recipes.validate_unlock(report, definition, "sweep")["passed"]) + for key, value in (("cases", []), ("potatoCraftingCases", 58), ("bookRecipes", []), ("status", "FAIL")): + changed = dict(report, **{key: value}) + with self.subTest(key=key), self.assertRaises(AssertionError): + recipes.validate_unlock(changed, definition, "sweep") + + def test_unlock_cannot_replace_missing_case_with_duplicate(self): + definition, report = self.unlock_report() + definition["recipes"].append({"id": "poweradvantage:other"}) + report["cases"] *= 2 + with self.assertRaises(AssertionError): + recipes.validate_unlock(report, definition, "sweep") + + def test_human_materials_gaps_remain_failures(self): + definition, report = self.unlock_report() + report["additionalMaterials"] = ["electricadvantage:control_circuit"] + with self.assertRaises(AssertionError): + recipes.validate_unlock(report, definition, "persist") + + def test_unavailable_ingredient_is_reported_as_skip_not_pass(self): + definition, report = self.unlock_report() + report["cases"][0]["status"] = "SKIP_NO_INGREDIENT_PROVIDER" + counts = recipes.validate_unlock(report, definition, "sweep") + self.assertEqual((0, 1), (counts["passed"], counts["skipped"])) + + def test_progression_rejects_empty_evidence_and_ignored_configuration(self): + report = {"status": "PASS", "mode": "NORMAL", "plasticEqualsRubber": True, "blocked": 0, + "blockedRecipes": [], "targets": [{"id": "poweradvantage:case", "status": "PASS"}], + "reachable": {"minecraft:iron_ingot": {}}, "executedRecipes": ["poweradvantage:case"], + "processWitnesses": [{"route": "live_furnace"}], "lootDataPackOverrideVerified": True} + with patch.object(recipes, "validate_tags"): + recipes.validate_progression(report, "NORMAL", True) + for key, value in (("mode", "APOCALYPTIC"), ("plasticEqualsRubber", False), ("blocked", 1), + ("blockedRecipes", ["missing"]), ("targets", []), ("processWitnesses", []), + ("lootDataPackOverrideVerified", False)): + with self.subTest(key=key), self.assertRaises(AssertionError): + recipes.validate_progression(dict(report, **{key: value}), "NORMAL", True) + + def test_original_natural_basis_and_exclusions_are_not_expanded(self): + from progression import fixture + original = fixture() + self.assertEqual(original, recipes.progression_fixture()) + self.assertNotIn("electricadvantage:psu", original["exclusions"]) + self.assertEqual(["electricadvantage:li_ore", "electricadvantage:sulfur_ore"], + [item for item in original["naturalResources"] if item.startswith("electricadvantage:")]) + + def test_target_requires_all_five_mods_and_native_harness(self): + manifest = self.root / "manifest.json" + manifest.write_text(json.dumps({"target115": {"jars": [{"id": "poweradvantage"}]}})) + with self.assertRaises(ValueError), patch.object(recipes, "pinned_profile") as resolve: + recipes.native_target(manifest) + resolve.assert_not_called() + + def test_missing_and_historical_packet_exports_cannot_claim_native_level(self): + runtime = self.root / "runtime" + with self.assertRaises(FileNotFoundError): + recipes.verify_level(runtime, {"jars": []}) + old_level = {"Data": {"Version": {"Name": "1.13.2"}}} + with patch.object(recipes.nbtlib, "load", return_value=old_level), self.assertRaises(AssertionError): + recipes.verify_level(runtime, {"jars": []}) + + def test_native_level_requires_nonempty_singular_item_and_block_registries(self): + level = {"Data": {"Version": {"Name": "1.15.2"}}, "fml": { + "LoadingModList": [{"ModId": "forge", "ModVersion": "31.2.57"}], + "Registries": {"minecraft:item": {"ids": [1]}, "minecraft:block": {"ids": [1]}}}} + with patch.object(recipes.nbtlib, "load", return_value=level), patch.object(recipes, "digest", return_value="test-hash"): + self.assertEqual("1.15.2", recipes.verify_level(self.root, {"jars": []})["minecraft"]) + for registry in ({"minecraft:items": {"ids": [1]}, "minecraft:blocks": {"ids": [1]}}, + {"minecraft:item": {"ids": []}, "minecraft:block": {"ids": [1]}}): + level["fml"]["Registries"] = registry + with self.subTest(registry=registry), patch.object(recipes.nbtlib, "load", return_value=level), self.assertRaises(AssertionError): + recipes.verify_level(self.root, {"jars": []}) + + def test_native_level_rejects_wrong_forge_or_mod_version(self): + level = {"Data": {"Version": {"Name": "1.15.2"}}, "fml": { + "LoadingModList": [{"ModId": "forge", "ModVersion": "25.0.223"}], "Registries": {}}} + with patch.object(recipes.nbtlib, "load", return_value=level), self.assertRaises(AssertionError): + recipes.verify_level(self.root, {"jars": []}) + level["fml"]["LoadingModList"][0]["ModVersion"] = "31.2.57" + with patch.object(recipes.nbtlib, "load", return_value=level), self.assertRaises(AssertionError): + recipes.verify_level(self.root, {"jars": [{"id": "poweradvantage", "version": "2.4.6.115021"}]}) + + def test_staging_only_copies_binaries_and_replaces_legacy_generator(self): + seed = self.root / "seed" + seed.mkdir() + (seed / "libraries").mkdir() + (seed / "forge-1.15.2-31.2.57.jar").write_bytes(b"test-launcher") + (seed / WORLD_NAME).mkdir() + protected = seed / WORLD_NAME / "untouched.dat" + protected.write_bytes(b"protected-existing-world") + runtime = self.root / "new-runtime" + target = seed, "forge-1.15.2-31.2.57.jar", [] + before = recipes.fingerprint(seed) + with patch.object(recipes, "require_free_port"): + recipes.stage_native(runtime, target, 25577, "unlock") + self.assertEqual(before, recipes.fingerprint(seed)) + self.assertFalse((runtime / WORLD_NAME).exists()) + self.assertTrue((runtime / "advantage-recipes.allow").exists()) + props = (runtime / "server.properties").read_text() + self.assertIn("server-port=25577\n", props) + self.assertIn("server-ip=127.0.0.1\n", props) + self.assertIn("generator-settings=\n", props) + self.assertNotIn("3;minecraft:bedrock", props) + with patch.object(recipes, "require_free_port"), self.assertRaises(FileExistsError): + recipes.stage_native(runtime, target, 25577, "unlock") + + def test_mode_pruning_requires_exact_native_packet_difference(self): + log = self.root / "console-tech.log" + event = "[Server thread/ERROR] [minecraft/ServerRecipeBook]: Tried to load unrecognized recipe: poweradvantage:normal_only removed now.\n" + log.write_text(event * 2) + result = recipes.audit_unlock_log(log, {"poweradvantage:normal_only", "poweradvantage:common"}, {"poweradvantage:common"}) + self.assertEqual(["poweradvantage:normal_only"], result["observed_removed_ids"]) + self.assertEqual(2, len(result["events"])) + with self.assertRaises(AssertionError): + recipes.audit_unlock_log(log, {"poweradvantage:normal_only"}, {"poweradvantage:normal_only"}) + log.write_text("") + with self.assertRaises(AssertionError): + recipes.audit_unlock_log(log, {"poweradvantage:normal_only"}, set()) + + def test_mode_pruning_never_accepts_unrelated_errors_or_bad_recipe_ids(self): + log = self.root / "console.log" + for message in ("[Server thread/ERROR] [minecraft/ServerRecipeBook]: Other failure", + "[Server thread/ERROR] [minecraft/ServerRecipeBook]: Tried to load improperly formatted recipe: bad removed now.", + "[Server thread/ERROR] [minecraft/LootTableManager]: Failed to reload data packs", + "NoSuchMethodError: native failure"): + log.write_text(message) + with self.subTest(message=message), self.assertRaises(AssertionError): + recipes.audit_unlock_log(log, set(), set()) + + +WORLD_NAME = recipes.WORLD + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_refresh.py b/test-rig/migration/test_native115_refresh.py new file mode 100644 index 0000000..bc512df --- /dev/null +++ b/test-rig/migration/test_native115_refresh.py @@ -0,0 +1,25 @@ +from pathlib import Path +import tempfile +import unittest + +from matrix import digest +from native115_refresh import snapshot_name + + +class RefreshProvenanceTests(unittest.TestCase): + def test_only_exact_archived_snapshot_is_selected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first = root / "migration-source.json" + second = root / "migration-restarted.json" + first.write_text("source") + second.write_text("different") + self.assertEqual(snapshot_name(root, digest(first)), first.name) + with self.assertRaisesRegex(AssertionError, "No retained"): + snapshot_name(root, "0" * 64) + second.write_text("source") + self.assertEqual(snapshot_name(root, digest(first)), second.name) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_respawn.py b/test-rig/migration/test_native115_respawn.py new file mode 100644 index 0000000..795dfde --- /dev/null +++ b/test-rig/migration/test_native115_respawn.py @@ -0,0 +1,71 @@ +import copy +import unittest +from unittest.mock import patch +import json +from pathlib import Path +import tempfile + +from native115_respawn import native_respawn_verdict, publish_definition +from native115_audit_respawn import replay + + +class NativeRespawnAdapterTest(unittest.TestCase): + def test_tick_reader_never_observes_definition_truncation(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "dimension-fixture.json" + old = {"dimension": -1, "respawn": {"bedMode": "forced", "keepInventory": True}} + new = {"dimension": -1, "respawn": {"bedMode": "bed", "keepInventory": True}} + path.write_text(json.dumps(old)) + observed = [] + def interleaved_writer(target, value): + target.write_text("") + observed.append(json.loads(path.read_text())) + target.write_text(json.dumps(value)) + with patch("native115_respawn.write_json", side_effect=interleaved_writer): + publish_definition(path, new) + self.assertEqual([old], observed) + self.assertEqual(new, json.loads(path.read_text())) + + def test_failed_definition_write_keeps_live_payload_and_cleans_temporary_file(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "dimension-fixture.json" + old = {"dimension": 1, "respawn": {"bedMode": "missing", "keepInventory": False}} + path.write_text(json.dumps(old)) + with patch("native115_respawn.write_json", side_effect=OSError("disk write failed")): + with self.assertRaisesRegex(OSError, "disk write failed"): + publish_definition(path, {"dimension": -1}) + self.assertEqual(old, json.loads(path.read_text())) + self.assertEqual([path], list(path.parent.iterdir())) + + def test_only_exact_native_handler_is_translated(self): + for handler, expected in (("ServerPlayNetHandler.PERFORM_RESPAWN", "NetHandlerPlayServer.PERFORM_RESPAWN"), + ("Other.PERFORM_RESPAWN", "Other.PERFORM_RESPAWN"), + ("NetHandlerPlayServer.PERFORM_RESPAWN", "NetHandlerPlayServer.PERFORM_RESPAWN")): + original = {"respawn": {"handler": handler}, "retain": {"Inventory": [17], "bed": "missing"}} + before = copy.deepcopy(original) + with patch("native115_respawn.respawn_verdict", return_value={"status": "TEST"}) as oracle: + self.assertEqual({"status": "TEST"}, native_respawn_verdict(original, {1: "enchantment"}, {"radius": 10})) + adapted = copy.deepcopy(before) + adapted["respawn"]["handler"] = expected + oracle.assert_called_once_with(adapted, {1: "enchantment"}, {"radius": 10}) + self.assertEqual(before, original) + + def test_aborted_campaign_never_becomes_a_pass(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + for name in ("results.json", "source-fingerprints.json"): + (root / name).write_text(json.dumps({})) + manifest = root / "archived-inputs.json" + manifest.write_text("{}") + with self.assertRaisesRegex(ValueError, "explicit pinned target manifest"): + replay(root) + with patch("native115_audit_respawn.pinned_profile") as pin: + result = replay(root, manifest) + pin.assert_called_once_with(manifest.resolve(), "target115") + self.assertEqual("FAIL", result["status"]) + self.assertIn("Campaign summary missing", result["findings"][0]) + self.assertFalse((root / "summary.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_ticket_networks.py b/test-rig/migration/test_native115_ticket_networks.py new file mode 100644 index 0000000..65fb48d --- /dev/null +++ b/test-rig/migration/test_native115_ticket_networks.py @@ -0,0 +1,95 @@ +"""Native layout safety and ticket-halo geometry, without accepting oracle failures.""" + +import copy +import json +import unittest +from unittest.mock import patch + +import extended_lifecycle +from native115_partial import NativePartialRun +from native115_ticket_networks import (NativeTicketNetworkRun, bridge_fixtures, extended_fixtures, + fill_commands, halo_chunks, linear_fixtures) + + +class NativeTicketFixtureTests(unittest.TestCase): + def test_historical_drill_fixtures_are_identical(self): + self.assertEqual([f for f in extended_lifecycle.fixtures() if "route" in f], extended_fixtures()[:18]) + + def test_all_native_bounds_and_paths_are_contained_and_do_not_overlap(self): + for definition in (linear_fixtures(), bridge_fixtures(), extended_fixtures()): + for index, f in enumerate(definition): + for p in f["witnesses"]: + self.assertTrue(all(a <= n <= b for a, n, b in zip(f["min"], p, f["max"]))) + for other in definition[index + 1:]: + self.assertTrue(any(f["max"][a] < other["min"][a] or other["max"][a] < f["min"][a] for a in range(3))) + + def test_retained_ticket_is_outside_selected_halo(self): + for f in linear_fixtures(): + for group, other in (("supply", "machine"), ("machine", "supply")): + self.assertTrue(set(map(tuple, f["groups"][group + "halo"])).isdisjoint(map(tuple, f["groups"][other]))) + core = f["groups"][group][0] + all_chunks = {(x, z) for x in range(f["min"][0] >> 4, (f["max"][0] >> 4) + 1) + for z in range(f["min"][2] >> 4, (f["max"][2] >> 4) + 1)} + retained = all_chunks - set(map(tuple, f["groups"][group + "halo"])) + self.assertTrue(all(max(abs(x-core[0]), abs(z-core[1])) > 2 for x, z in retained)) + + def test_bridge_halo_does_not_remove_endpoint_ticket(self): + for f in bridge_fixtures(): + self.assertTrue(set(map(tuple, f["groups"]["bridgehalo"])).isdisjoint(map(tuple, f["groups"]["endpoints"]))) + self.assertEqual(f["groups"]["bridgehalo"], halo_chunks(f, "bridge")) + + def test_linear_path_is_native_neighbor_connected_and_128_blocks_long(self): + for f in linear_fixtures(): + path = [f["witnesses"][0], *f["pipes"], f["witnesses"][4]] + self.assertEqual(len(path), 129) + self.assertTrue(all(sum(abs(x-y) for x, y in zip(a, b)) == 1 for a, b in zip(path, path[1:]))) + self.assertEqual(len(set(map(tuple, f["witnesses"]))), len(f["witnesses"])) + + def test_bridge_is_one_connected_u_without_shortcut(self): + for f in bridge_fixtures(): + path = [f["witnesses"][0], *f["pipes"], f["witnesses"][1]] + self.assertTrue(all(sum(abs(x-y) for x, y in zip(a, b)) == 1 for a, b in zip(path, path[1:]))) + self.assertEqual(len(set(map(tuple, path))), len(path)) + self.assertEqual(len(f["pipes"]), 259) + + def test_clearing_is_split_and_exactly_confined_to_declared_bounds(self): + for f in linear_fixtures() + bridge_fixtures(): + command = "fill " + " ".join(map(str, f["min"] + f["max"])) + " minecraft:air replace" + covered = set() + for piece in fill_commands([f], command): + p = piece.split() + low, high = list(map(int, p[1:4])), list(map(int, p[4:7])) + volume = 1 + for a, b in zip(low, high): + volume *= b - a + 1 + self.assertLessEqual(volume, 32768) + cells = {(x, z) for x in range(low[0], high[0] + 1) for z in range(low[2], high[2] + 1)} + self.assertTrue(covered.isdisjoint(cells)) + covered |= cells + self.assertEqual(len(covered), 160 * 32) + outside = command.replace(str(f["min"][0]), "0", 1) + self.assertIsNone(fill_commands([f], outside)) + + def test_release_observes_before_releasing_halo_then_core(self): + runner = NativeTicketNetworkRun(None, None, None, linear_fixtures()) + before = {"raw": "before"} + with patch.object(runner, "_raw_probe", side_effect=[before, {}, {"raw": "after"}]) as raw: + runner.probe("release-supply", "released") + self.assertEqual([call.args[0] for call in raw.call_args_list], + ["snapshot", "release-supplyhalo", "release-supply"]) + self.assertIs(runner._released_groups["supply"]["before"], before) + + def test_load_restores_only_requested_halo_before_core_readiness(self): + runner = NativeTicketNetworkRun(None, None, None, linear_fixtures()) + runner._released_groups["supply"] = {} + runner._released_groups["machine"] = {} + with patch.object(runner, "_raw_probe") as raw, patch.object(runner, "_load_ready", return_value={}) as ready: + runner.probe("load-supply", "loaded") + self.assertEqual(raw.call_args.args[0], "load-supplyhalo") + ready.assert_called_once_with("load-supply", "loaded") + self.assertNotIn("supply", runner._released_groups) + self.assertIn("machine", runner._released_groups) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native115_transformer_order.py b/test-rig/migration/test_native115_transformer_order.py new file mode 100644 index 0000000..46657c6 --- /dev/null +++ b/test-rig/migration/test_native115_transformer_order.py @@ -0,0 +1,35 @@ +import unittest +import json + +import transformer_order as historical +import test_transformer_order as historical_tests +from native115_transformer_order import BRIDGE_LIBRARIES, COREMODS, SUFFIXES, native_order_evidence + + +class NativeTransformerOrderAdapterTest(unittest.TestCase): + def test_oracle_code_is_unchanged_with_only_platform_targets_bound(self): + self.assertIs(historical.order_evidence.__code__, native_order_evidence.__code__) + self.assertEqual("net/minecraft/world/storage/SaveFormat", SUFFIXES["world_info"][0]) + self.assertEqual("net/minecraft/world/chunk/storage/ChunkLoader", SUFFIXES["chunk_status"][0]) + self.assertEqual(historical.SUFFIXES["worldgen_guard"], SUFFIXES["worldgen_guard"]) + self.assertNotEqual(historical.SUFFIXES, SUFFIXES) + self.assertEqual("coremods-2.0.3.jar", COREMODS.name) + self.assertEqual(("asm-7.2.jar", "asm-tree-7.2.jar", "modlauncher-5.1.2.jar"), + tuple(path.rsplit("/", 1)[1] for path in BRIDGE_LIBRARIES)) + + +class NativeOrderEvidenceNegativeControls(historical_tests.OrderEvidenceTests): + def setUp(self): + super().setUp() + mapping = {old[0]: SUFFIXES[suffix][0] for suffix, old in historical.SUFFIXES.items()} + for event in self.events: + if "class" in event: + event["class"] = mapping[event["class"]] + + def prove(self): + (self.root / "transformer-order-events.jsonl").write_text("\n".join(json.dumps(event) for event in self.events)) + return native_order_evidence(self.root, "bm-first") + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/profiles/native115/dev-core-apocalyptic.json b/test-rig/profiles/native115/dev-core-apocalyptic.json new file mode 100644 index 0000000..601eb50 --- /dev/null +++ b/test-rig/profiles/native115/dev-core-apocalyptic.json @@ -0,0 +1,60 @@ +{ + "name": "dev-core-apocalyptic", + "mode": "development", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "shutdownTimeoutSeconds": 60, + "stationRunSeconds": 5, + "recipeMode": "APOCALYPTIC", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "lifecycleStations": [], + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021-deobf.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021-deobf.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs-dev\\AdvantageWorksTestHarness-1.1.0.115021-deobf.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021-deobf.jar", + "path": "build/native115-artifacts/CB02BE3E9CCD1E4498000503D825B2B6FD350EBDF37A6414EE23697A22BE925E/SteamAdvantage-2.2.7.115021-deobf.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs-dev\\SteamAdvantage-2.2.7.115021-deobf.jar", + "sha256": "CB02BE3E9CCD1E4498000503D825B2B6FD350EBDF37A6414EE23697A22BE925E", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021-deobf.jar", + "path": "build/native115-artifacts/7CF50EA6FBEB30F1683359EFF5C541B6674A30D12AA4C7D0B236CE5E52D9D7DF/ElectricAdvantage-2.2.7.115021-deobf.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs-dev\\ElectricAdvantage-2.2.7.115021-deobf.jar", + "sha256": "7CF50EA6FBEB30F1683359EFF5C541B6674A30D12AA4C7D0B236CE5E52D9D7DF", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/dev-core-apocalyptic.json", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "generatorSettings": "", + "inputStatus": "PINNED_INPUTS" +} diff --git a/test-rig/profiles/native115/dev-core-tech-progression.json b/test-rig/profiles/native115/dev-core-tech-progression.json new file mode 100644 index 0000000..86ccf33 --- /dev/null +++ b/test-rig/profiles/native115/dev-core-tech-progression.json @@ -0,0 +1,60 @@ +{ + "name": "dev-core-tech-progression", + "mode": "development", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "shutdownTimeoutSeconds": 60, + "stationRunSeconds": 5, + "recipeMode": "TECH_PROGRESSION", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "lifecycleStations": [], + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021-deobf.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021-deobf.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs-dev\\AdvantageWorksTestHarness-1.1.0.115021-deobf.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021-deobf.jar", + "path": "build/native115-artifacts/CB02BE3E9CCD1E4498000503D825B2B6FD350EBDF37A6414EE23697A22BE925E/SteamAdvantage-2.2.7.115021-deobf.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs-dev\\SteamAdvantage-2.2.7.115021-deobf.jar", + "sha256": "CB02BE3E9CCD1E4498000503D825B2B6FD350EBDF37A6414EE23697A22BE925E", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021-deobf.jar", + "path": "build/native115-artifacts/7CF50EA6FBEB30F1683359EFF5C541B6674A30D12AA4C7D0B236CE5E52D9D7DF/ElectricAdvantage-2.2.7.115021-deobf.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs-dev\\ElectricAdvantage-2.2.7.115021-deobf.jar", + "sha256": "7CF50EA6FBEB30F1683359EFF5C541B6674A30D12AA4C7D0B236CE5E52D9D7DF", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/dev-core-tech-progression.json", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "generatorSettings": "", + "inputStatus": "PINNED_INPUTS" +} diff --git a/test-rig/profiles/native115/dev-core.json b/test-rig/profiles/native115/dev-core.json new file mode 100644 index 0000000..58a11a7 --- /dev/null +++ b/test-rig/profiles/native115/dev-core.json @@ -0,0 +1,59 @@ +{ + "name": "dev-core", + "mode": "development", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "shutdownTimeoutSeconds": 60, + "stationRunSeconds": 5, + "recipeMode": "NORMAL", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021-deobf.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021-deobf.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs-dev\\AdvantageWorksTestHarness-1.1.0.115021-deobf.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021-deobf.jar", + "path": "build/native115-artifacts/CB02BE3E9CCD1E4498000503D825B2B6FD350EBDF37A6414EE23697A22BE925E/SteamAdvantage-2.2.7.115021-deobf.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs-dev\\SteamAdvantage-2.2.7.115021-deobf.jar", + "sha256": "CB02BE3E9CCD1E4498000503D825B2B6FD350EBDF37A6414EE23697A22BE925E", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021-deobf.jar", + "path": "build/native115-artifacts/7CF50EA6FBEB30F1683359EFF5C541B6674A30D12AA4C7D0B236CE5E52D9D7DF/ElectricAdvantage-2.2.7.115021-deobf.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs-dev\\ElectricAdvantage-2.2.7.115021-deobf.jar", + "sha256": "7CF50EA6FBEB30F1683359EFF5C541B6674A30D12AA4C7D0B236CE5E52D9D7DF", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/dev-core.json", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "generatorSettings": "", + "inputStatus": "PINNED_INPUTS" +} diff --git a/test-rig/profiles/native115/packaged-current.json b/test-rig/profiles/native115/packaged-current.json new file mode 100644 index 0000000..c1ad47d --- /dev/null +++ b/test-rig/profiles/native115/packaged-current.json @@ -0,0 +1,92 @@ +{ + "name": "packaged-current", + "mode": "packaged", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "shutdownTimeoutSeconds": 60, + "stationRunSeconds": 5, + "recipeMode": "NORMAL", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs\\AdvantageWorksTestHarness-1.1.0.115021.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "poweradvantage", + "version": "2.4.6.115021", + "file": "PowerAdvantage-2.4.6.115021.jar", + "path": "build/native115-artifacts/F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C/PowerAdvantage-2.4.6.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\build\\libs\\PowerAdvantage-2.4.6.115021.jar", + "sha256": "F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C", + "sourceRef": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2/SteamAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs\\SteamAdvantage-2.2.7.115021.jar", + "sha256": "564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E/ElectricAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs\\ElectricAdvantage-2.2.7.115021.jar", + "sha256": "C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "basemetals", + "version": "3.0.2.115021", + "file": "BaseMetals-3.0.2.115021.jar", + "path": "build/native115-artifacts/3F50AFBEF17800ECADE42A69B81E541E9FA8C6C4C392ED4DD0FE5A66DC9EFF07/BaseMetals-3.0.2.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\BaseMetals\\BaseMetals 115\\BaseMetals\\build\\libs\\BaseMetals-3.0.2.115021.jar", + "sha256": "3F50AFBEF17800ECADE42A69B81E541E9FA8C6C4C392ED4DD0FE5A66DC9EFF07", + "sourceRef": "ac1781e7cade15045ffe0135077087b4d1722603", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "orespawn", + "version": "4.1.1.115021", + "file": "OreSpawn-4.1.1.115021.jar", + "path": "build/native115-artifacts/8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913/OreSpawn-4.1.1.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\MinecraftMineralogy\\OS115\\OreSpawn\\build\\libs\\OreSpawn-4.1.1.115021.jar", + "sha256": "8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913", + "sourceRef": "5613baf876747ed071cb7ad7e2aff4e0ce4f0a03", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/packaged-current.json", + "generatorSettings": "", + "inputStatus": "PINNED_INPUTS" +} diff --git a/test-rig/profiles/native115/packaged-decoupled.json b/test-rig/profiles/native115/packaged-decoupled.json new file mode 100644 index 0000000..c135aef --- /dev/null +++ b/test-rig/profiles/native115/packaged-decoupled.json @@ -0,0 +1,81 @@ +{ + "name": "packaged-decoupled", + "mode": "packaged", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "shutdownTimeoutSeconds": 60, + "stationRunSeconds": 5, + "recipeMode": "NORMAL", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs\\AdvantageWorksTestHarness-1.1.0.115021.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "poweradvantage", + "version": "2.4.6.115021", + "file": "PowerAdvantage-2.4.6.115021.jar", + "path": "build/native115-artifacts/F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C/PowerAdvantage-2.4.6.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\build\\libs\\PowerAdvantage-2.4.6.115021.jar", + "sha256": "F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C", + "sourceRef": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2/SteamAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs\\SteamAdvantage-2.2.7.115021.jar", + "sha256": "564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E/ElectricAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs\\ElectricAdvantage-2.2.7.115021.jar", + "sha256": "C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "orespawn", + "version": "4.1.1.115021", + "file": "OreSpawn-4.1.1.115021.jar", + "path": "build/native115-artifacts/8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913/OreSpawn-4.1.1.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\MinecraftMineralogy\\OS115\\OreSpawn\\build\\libs\\OreSpawn-4.1.1.115021.jar", + "sha256": "8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913", + "sourceRef": "5613baf876747ed071cb7ad7e2aff4e0ce4f0a03", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/packaged-decoupled.json", + "generatorSettings": "", + "inputStatus": "PINNED_INPUTS" +} diff --git a/test-rig/profiles/native115/packaged-mineralogy.json b/test-rig/profiles/native115/packaged-mineralogy.json new file mode 100644 index 0000000..1729623 --- /dev/null +++ b/test-rig/profiles/native115/packaged-mineralogy.json @@ -0,0 +1,96 @@ +{ + "name": "packaged-mineralogy", + "mode": "packaged", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "shutdownTimeoutSeconds": 60, + "stationRunSeconds": 5, + "recipeMode": "NORMAL", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs\\AdvantageWorksTestHarness-1.1.0.115021.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "poweradvantage", + "version": "2.4.6.115021", + "file": "PowerAdvantage-2.4.6.115021.jar", + "path": "build/native115-artifacts/F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C/PowerAdvantage-2.4.6.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\build\\libs\\PowerAdvantage-2.4.6.115021.jar", + "sha256": "F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C", + "sourceRef": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2/SteamAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs\\SteamAdvantage-2.2.7.115021.jar", + "sha256": "564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E/ElectricAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs\\ElectricAdvantage-2.2.7.115021.jar", + "sha256": "C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "orespawn", + "version": "4.1.1.115021", + "file": "OreSpawn-4.1.1.115021.jar", + "path": "build/native115-artifacts/8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913/OreSpawn-4.1.1.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\MinecraftMineralogy\\OS115\\OreSpawn\\build\\libs\\OreSpawn-4.1.1.115021.jar", + "sha256": "8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913", + "sourceRef": "5613baf876747ed071cb7ad7e2aff4e0ce4f0a03", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "mineralogy", + "version": "6.0.1.115021", + "file": "Mineralogy-6.0.1.115021.jar", + "path": "build/native115-artifacts/ADACF365CCAFF7091980AA92ED1FCC14892AA6CD4B10E82336C8512525341DFA/Mineralogy-6.0.1.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\MinecraftMineralogy\\MinecraftMineralogy 115\\MinecraftMineralogy\\build\\libs\\Mineralogy-6.0.1.115021.jar", + "sha256": "ADACF365CCAFF7091980AA92ED1FCC14892AA6CD4B10E82336C8512525341DFA", + "sourceRef": "46a5def9d2d76854ec696b6dc11ae73ffb65eacb", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "lifecycleStations": [ + "W-03", + "S-01" + ], + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/packaged-mineralogy.json", + "generatorSettings": "", + "inputStatus": "PINNED_INPUTS" +} diff --git a/test-rig/profiles/native115/packaged-optional-power.json b/test-rig/profiles/native115/packaged-optional-power.json new file mode 100644 index 0000000..6e393dc --- /dev/null +++ b/test-rig/profiles/native115/packaged-optional-power.json @@ -0,0 +1,81 @@ +{ + "name": "packaged-optional-power", + "mode": "packaged", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "shutdownTimeoutSeconds": 60, + "stationRunSeconds": 5, + "recipeMode": "NORMAL", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs\\AdvantageWorksTestHarness-1.1.0.115021.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "poweradvantage", + "version": "2.4.6.115021", + "file": "PowerAdvantage-2.4.6.115021.jar", + "path": "build/native115-artifacts/F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C/PowerAdvantage-2.4.6.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\build\\libs\\PowerAdvantage-2.4.6.115021.jar", + "sha256": "F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C", + "sourceRef": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2/SteamAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs\\SteamAdvantage-2.2.7.115021.jar", + "sha256": "564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E/ElectricAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs\\ElectricAdvantage-2.2.7.115021.jar", + "sha256": "C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "orespawn", + "version": "4.1.1.115021", + "file": "OreSpawn-4.1.1.115021.jar", + "path": "build/native115-artifacts/8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913/OreSpawn-4.1.1.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\MinecraftMineralogy\\OS115\\OreSpawn\\build\\libs\\OreSpawn-4.1.1.115021.jar", + "sha256": "8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913", + "sourceRef": "5613baf876747ed071cb7ad7e2aff4e0ce4f0a03", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/packaged-optional-power.json", + "generatorSettings": "", + "inputStatus": "PINNED_INPUTS" +} diff --git a/test-rig/profiles/native115/worldgen-advantage.json b/test-rig/profiles/native115/worldgen-advantage.json new file mode 100644 index 0000000..26f0d79 --- /dev/null +++ b/test-rig/profiles/native115/worldgen-advantage.json @@ -0,0 +1,173 @@ +{ + "mode": "packaged", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "stationRunSeconds": 5, + "recipeMode": "NORMAL", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs\\AdvantageWorksTestHarness-1.1.0.115021.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "poweradvantage", + "version": "2.4.6.115021", + "file": "PowerAdvantage-2.4.6.115021.jar", + "path": "build/native115-artifacts/F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C/PowerAdvantage-2.4.6.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\build\\libs\\PowerAdvantage-2.4.6.115021.jar", + "sha256": "F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C", + "sourceRef": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2/SteamAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs\\SteamAdvantage-2.2.7.115021.jar", + "sha256": "564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E/ElectricAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs\\ElectricAdvantage-2.2.7.115021.jar", + "sha256": "C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "orespawn", + "version": "4.1.1.115021", + "file": "OreSpawn-4.1.1.115021.jar", + "path": "build/native115-artifacts/8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913/OreSpawn-4.1.1.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\MinecraftMineralogy\\OS115\\OreSpawn\\build\\libs\\OreSpawn-4.1.1.115021.jar", + "sha256": "8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913", + "sourceRef": "5613baf876747ed071cb7ad7e2aff4e0ce4f0a03", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "name": "worldgen-advantage", + "levelName": "AdvantageWorksWorldgen", + "levelSeed": "advantage-works-worldgen-1.15.2-v1", + "levelType": "DEFAULT", + "generatorSettings": "", + "buildWorks": false, + "worldgenSampleRadius": 8, + "shutdownTimeoutSeconds": 300, + "lifecycleStations": [], + "firstProfileAssertions": [ + { + "id": "desert-oil-visible", + "section": "fluid_deposits", + "entry": "poweradvantage:fluid_deposit/desert_crude_oil", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "desert-oil-owner", + "section": "fluid_deposits", + "entry": "poweradvantage:fluid_deposit/desert_crude_oil", + "present": true, + "field": "source_provider", + "expectedString": "poweradvantage" + }, + { + "id": "electric-sulfur-default", + "section": "ores", + "entry": "electricadvantage:ore/sulfur", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "electric-lithium-default", + "section": "ores", + "entry": "electricadvantage:ore/lithium", + "present": true, + "field": "enabled", + "expectedBoolean": true + } + ], + "profileMutations": [ + { + "section": "fluid_deposits", + "entry": "poweradvantage:fluid_deposit/desert_crude_oil", + "field": "enabled", + "booleanValue": false + }, + { + "section": "ores", + "entry": "electricadvantage:ore/sulfur", + "field": "enabled", + "booleanValue": false + } + ], + "restartProfileAssertions": [ + { + "id": "desert-oil-user-disable-persists", + "section": "fluid_deposits", + "entry": "poweradvantage:fluid_deposit/desert_crude_oil", + "present": true, + "field": "enabled", + "expectedBoolean": false + }, + { + "id": "electric-sulfur-user-disable-persists", + "section": "ores", + "entry": "electricadvantage:ore/sulfur", + "present": true, + "field": "enabled", + "expectedBoolean": false + }, + { + "id": "electric-lithium-remains-enabled", + "section": "ores", + "entry": "electricadvantage:ore/lithium", + "present": true, + "field": "enabled", + "expectedBoolean": true + } + ], + "worldgenExpectations": { + "minimumCounts": { + "poweradvantage:crude_oil": 1, + "electricadvantage:li_ore": 1, + "electricadvantage:sulfur_ore": 1 + }, + "maximumCounts": { + "mineralogy:crude_oil": 0, + "mineralogy:sulfur_ore": 0 + }, + "zeroBiomeViolations": true + }, + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/worldgen-advantage.json", + "inputStatus": "PINNED_INPUTS" +} diff --git a/test-rig/profiles/native115/worldgen-legacy-import.json b/test-rig/profiles/native115/worldgen-legacy-import.json new file mode 100644 index 0000000..311aef0 --- /dev/null +++ b/test-rig/profiles/native115/worldgen-legacy-import.json @@ -0,0 +1,240 @@ +{ + "mode": "packaged", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "stationRunSeconds": 5, + "recipeMode": "NORMAL", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs\\AdvantageWorksTestHarness-1.1.0.115021.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "poweradvantage", + "version": "2.4.6.115021", + "file": "PowerAdvantage-2.4.6.115021.jar", + "path": "build/native115-artifacts/F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C/PowerAdvantage-2.4.6.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\build\\libs\\PowerAdvantage-2.4.6.115021.jar", + "sha256": "F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C", + "sourceRef": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2/SteamAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs\\SteamAdvantage-2.2.7.115021.jar", + "sha256": "564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E/ElectricAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs\\ElectricAdvantage-2.2.7.115021.jar", + "sha256": "C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "orespawn", + "version": "4.1.1.115021", + "file": "OreSpawn-4.1.1.115021.jar", + "path": "build/native115-artifacts/8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913/OreSpawn-4.1.1.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\MinecraftMineralogy\\OS115\\OreSpawn\\build\\libs\\OreSpawn-4.1.1.115021.jar", + "sha256": "8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913", + "sourceRef": "5613baf876747ed071cb7ad7e2aff4e0ce4f0a03", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "name": "worldgen-legacy-import", + "levelName": "AdvantageWorksWorldgen", + "levelSeed": "advantage-works-worldgen-1.15.2-v1", + "levelType": "DEFAULT", + "generatorSettings": "", + "buildWorks": false, + "worldgenSampleRadius": 8, + "stableWorldgenCountExclusions": [ + "poweradvantage:crude_oil" + ], + "shutdownTimeoutSeconds": 300, + "lifecycleStations": [], + "configFixtures": [ + { + "source": "../../../PA113/PowerAdvantage/test-rig/../src/test/resources/orespawn-legacy/poweradvantage.json", + "destination": "config/orespawn/poweradvantage.json" + }, + { + "source": "../../../PA113/PowerAdvantage/test-rig/../../ElectricAdvantage/ElectricAdvantage/src/test/resources/orespawn-legacy/electricadvantage.json", + "destination": "config/orespawn/electricadvantage.json" + } + ], + "firstProfileAssertions": [ + { + "id": "legacy-oil-retained", + "section": "ores", + "entry": "poweradvantage:legacy/poweradvantage_crude_oil_0", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "legacy-lithium-retained", + "section": "ores", + "entry": "electricadvantage:legacy/electricadvantage_li_ore_0", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "legacy-sulfur-retained", + "section": "ores", + "entry": "electricadvantage:legacy/electricadvantage_sulfur_ore_1", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "modern-oil-not-duplicated", + "section": "fluid_deposits", + "entry": "poweradvantage:fluid_deposit/desert_crude_oil", + "present": false + }, + { + "id": "modern-lithium-not-duplicated", + "section": "ores", + "entry": "electricadvantage:ore/lithium", + "present": false + }, + { + "id": "modern-sulfur-not-duplicated", + "section": "ores", + "entry": "electricadvantage:ore/sulfur", + "present": false + } + ], + "restartProfileAssertions": [ + { + "id": "legacy-oil-retained", + "section": "ores", + "entry": "poweradvantage:legacy/poweradvantage_crude_oil_0", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "legacy-lithium-retained", + "section": "ores", + "entry": "electricadvantage:legacy/electricadvantage_li_ore_0", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "legacy-sulfur-retained", + "section": "ores", + "entry": "electricadvantage:legacy/electricadvantage_sulfur_ore_1", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "modern-oil-not-duplicated", + "section": "fluid_deposits", + "entry": "poweradvantage:fluid_deposit/desert_crude_oil", + "present": false + }, + { + "id": "modern-lithium-not-duplicated", + "section": "ores", + "entry": "electricadvantage:ore/lithium", + "present": false + }, + { + "id": "modern-sulfur-not-duplicated", + "section": "ores", + "entry": "electricadvantage:ore/sulfur", + "present": false + } + ], + "firstFileAssertions": [ + { + "id": "migration-report", + "path": "config/orespawn-os3-migration-report.json", + "contains": "os1_config_source", + "minimumOccurrences": 2 + }, + { + "id": "power-provider", + "path": "config/poweradvantage-orespawn.json", + "contains": "poweradvantage:legacy/poweradvantage_crude_oil_0", + "minimumOccurrences": 1 + }, + { + "id": "electric-provider", + "path": "config/electricadvantage-orespawn.json", + "contains": "electricadvantage:legacy/electricadvantage_li_ore_0", + "minimumOccurrences": 1 + } + ], + "restartFileAssertions": [ + { + "id": "migration-report", + "path": "config/orespawn-os3-migration-report.json", + "contains": "os1_config_source", + "minimumOccurrences": 2 + }, + { + "id": "power-provider", + "path": "config/poweradvantage-orespawn.json", + "contains": "poweradvantage:legacy/poweradvantage_crude_oil_0", + "minimumOccurrences": 1 + }, + { + "id": "electric-provider", + "path": "config/electricadvantage-orespawn.json", + "contains": "electricadvantage:legacy/electricadvantage_li_ore_0", + "minimumOccurrences": 1 + } + ], + "worldgenExpectations": { + "minimumCounts": { + "poweradvantage:crude_oil": 1, + "electricadvantage:li_ore": 1, + "electricadvantage:sulfur_ore": 1 + }, + "maximumCounts": { + "mineralogy:crude_oil": 0, + "mineralogy:sulfur_ore": 0 + }, + "zeroBiomeViolations": false + }, + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/worldgen-legacy-import.json", + "inputStatus": "PINNED_INPUTS" +} diff --git a/test-rig/profiles/native115/worldgen-mineralogy.json b/test-rig/profiles/native115/worldgen-mineralogy.json new file mode 100644 index 0000000..2ed01ff --- /dev/null +++ b/test-rig/profiles/native115/worldgen-mineralogy.json @@ -0,0 +1,186 @@ +{ + "mode": "packaged", + "runtimeBase": "build/works-native115", + "serverPort": 25565, + "startupTimeoutSeconds": 180, + "stationRunSeconds": 5, + "recipeMode": "NORMAL", + "sourceCommit": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "sourceVersion": "2.4.6.115021", + "forge": { + "version": "1.15.2-31.2.57", + "installerPath": "build/native115-cache/forge-1.15.2-31.2.57-installer.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.15.2-31.2.57/forge-1.15.2-31.2.57-installer.jar", + "sha256": "84FD49A743978421B26F62AC78222289FE892A46F996CF11CB5E05B7441A7773" + }, + "mods": [ + { + "id": "advantageworkstest", + "version": "1.1.0.115021", + "file": "AdvantageWorksTestHarness-1.1.0.115021.jar", + "path": "build/native115-artifacts/C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B/AdvantageWorksTestHarness-1.1.0.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\test-rig\\build\\libs\\AdvantageWorksTestHarness-1.1.0.115021.jar", + "sha256": "C854AFC08344C9B5E53AC8B35F9AA0B3E3F60E8180BF1C5F40AC7B5DF594890B", + "sourceRef": "3237cc41da51d17a851a32691a0fa1f19b9ad2b6", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "poweradvantage", + "version": "2.4.6.115021", + "file": "PowerAdvantage-2.4.6.115021.jar", + "path": "build/native115-artifacts/F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C/PowerAdvantage-2.4.6.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\PowerAdvantage\\build\\libs\\PowerAdvantage-2.4.6.115021.jar", + "sha256": "F86454B9B5282A195F5C8FC58BEDEDB751DE3EB55039A9AC3A9A8495B1C0293C", + "sourceRef": "75d24d2641e8123ff7a7f961d9a1e5a1d5203194", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "steamadvantage", + "version": "2.2.7.115021", + "file": "SteamAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2/SteamAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\SteamAdvantage\\SteamAdvantage\\build\\libs\\SteamAdvantage-2.2.7.115021.jar", + "sha256": "564F730D3D0C79A0C73776120D40D634C668B7C7A8E2E85350A8C753FE64C3D2", + "sourceRef": "7883d9b697a7c0961c721f01baa6ed1046d256de", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "electricadvantage", + "version": "2.2.7.115021", + "file": "ElectricAdvantage-2.2.7.115021.jar", + "path": "build/native115-artifacts/C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E/ElectricAdvantage-2.2.7.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\PowerAdvantage\\PA115\\ElectricAdvantage\\ElectricAdvantage\\build\\libs\\ElectricAdvantage-2.2.7.115021.jar", + "sha256": "C4084B709221C9D9E9175B342B692C3CF4278790EEA1AA88FFBBF8150354249E", + "sourceRef": "f39a7f8f2f09727cd8dec1a1ebc553d52d710404", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "orespawn", + "version": "4.1.1.115021", + "file": "OreSpawn-4.1.1.115021.jar", + "path": "build/native115-artifacts/8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913/OreSpawn-4.1.1.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\MinecraftMineralogy\\OS115\\OreSpawn\\build\\libs\\OreSpawn-4.1.1.115021.jar", + "sha256": "8E36F1B093F2ACFFB97A2ACD0579A8B585ACBBD6996F3853AFAAE54EBBEC2913", + "sourceRef": "5613baf876747ed071cb7ad7e2aff4e0ce4f0a03", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + }, + { + "id": "mineralogy", + "version": "6.0.1.115021", + "file": "Mineralogy-6.0.1.115021.jar", + "path": "build/native115-artifacts/ADACF365CCAFF7091980AA92ED1FCC14892AA6CD4B10E82336C8512525341DFA/Mineralogy-6.0.1.115021.jar", + "workspaceProduced": true, + "originPath": "D:\\MinecraftMineralogy\\MinecraftMineralogy 115\\MinecraftMineralogy\\build\\libs\\Mineralogy-6.0.1.115021.jar", + "sha256": "ADACF365CCAFF7091980AA92ED1FCC14892AA6CD4B10E82336C8512525341DFA", + "sourceRef": "46a5def9d2d76854ec696b6dc11ae73ffb65eacb", + "pinStatus": "PINNED_WORKSPACE_ARTIFACT" + } + ], + "name": "worldgen-mineralogy", + "levelName": "AdvantageWorksWorldgen", + "levelSeed": "advantage-works-worldgen-1.15.2-v1", + "levelType": "DEFAULT", + "generatorSettings": "", + "buildWorks": false, + "worldgenSampleRadius": 16, + "shutdownTimeoutSeconds": 300, + "lifecycleStations": [], + "firstProfileAssertions": [ + { + "id": "desert-oil-enabled", + "section": "fluid_deposits", + "entry": "poweradvantage:fluid_deposit/desert_crude_oil", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "ocean-oil-enabled", + "section": "fluid_deposits", + "entry": "mineralogy:fluid_deposit/crude_oil", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "mineralogy-sulfur-enabled", + "section": "ores", + "entry": "mineralogy:ore/mineralogy/sulfur_ore", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "electric-sulfur-disabled", + "section": "ores", + "entry": "electricadvantage:ore/sulfur", + "present": true, + "field": "enabled", + "expectedBoolean": false + }, + { + "id": "electric-lithium-fallback-enabled", + "section": "ores", + "entry": "electricadvantage:ore/lithium", + "present": true, + "field": "enabled", + "expectedBoolean": true + } + ], + "profileMutations": [ + { + "section": "ores", + "entry": "electricadvantage:ore/sulfur", + "field": "enabled", + "booleanValue": true + } + ], + "restartProfileAssertions": [ + { + "id": "electric-sulfur-user-enable-persists", + "section": "ores", + "entry": "electricadvantage:ore/sulfur", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "mineralogy-sulfur-remains-enabled", + "section": "ores", + "entry": "mineralogy:ore/mineralogy/sulfur_ore", + "present": true, + "field": "enabled", + "expectedBoolean": true + }, + { + "id": "ocean-oil-remains-enabled", + "section": "fluid_deposits", + "entry": "mineralogy:fluid_deposit/crude_oil", + "present": true, + "field": "enabled", + "expectedBoolean": true + } + ], + "worldgenExpectations": { + "minimumCounts": { + "poweradvantage:crude_oil": 1, + "mineralogy:crude_oil": 1, + "mineralogy:sulfur_ore": 1, + "electricadvantage:li_ore": 1 + }, + "maximumCounts": { + "electricadvantage:sulfur_ore": 0 + }, + "zeroBiomeViolations": true + }, + "minecraftVersion": "1.15.2", + "portStatus": "PORTED_UNQUALIFIED", + "qualificationStatus": "UNQUALIFIED", + "legacySourceProfile": "../../../PA113/PowerAdvantage/test-rig/profiles/worldgen-mineralogy.json", + "inputStatus": "PINNED_INPUTS" +} 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 7d17d78..14c5598 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.0.114041"; + public static final String VERSION = "1.1.0.115021"; public AdvantageWorksMod() { MigrationProbe.register(); @@ -18,6 +18,7 @@ public AdvantageWorksMod() { RecipeUnlockProbe.register(); ProgressionProbe.register(); NativeInteropProbe.register(); + NativeHarvestProbe.register(); WorksController.getInstance().loadDefinition(); MinecraftForge.EVENT_BUS.addListener(this::serverStarting); } 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 bb9081e..9ab44cc 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 @@ -275,7 +275,7 @@ private static JsonObject respawn(MinecraftServer server, ServerWorld world, Jso destination.setBlockState(SPAWN.east(), Blocks.RED_BED.getDefaultState().with(BedBlock.HORIZONTAL_FACING, Direction.EAST) .with(BedBlock.PART, net.minecraft.state.properties.BedPart.HEAD), 2); } - player.setSpawnPoint(SPAWN, bedMode.equals("forced"), DimensionType.OVERWORLD); + player.setSpawnPoint(SPAWN, bedMode.equals("forced"), false, DimensionType.OVERWORLD); JsonObject result = new JsonObject(); result.addProperty("scope", "server-only-dummy-network"); result.addProperty("handler", "ServerPlayNetHandler.PERFORM_RESPAWN"); result.addProperty("keepInventory", keep); result.addProperty("bedMode", bedMode); diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/JsonNbtConverter.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/JsonNbtConverter.java index 31a9cd9..353c1f0 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/JsonNbtConverter.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/JsonNbtConverter.java @@ -39,14 +39,14 @@ private static INBT toTag(JsonElement element) { return result; } JsonPrimitive primitive = element.getAsJsonPrimitive(); - if (primitive.isBoolean()) return new ByteNBT((byte) (primitive.getAsBoolean() ? 1 : 0)); - if (primitive.isString()) return new StringNBT(primitive.getAsString()); + if (primitive.isBoolean()) return ByteNBT.valueOf((byte) (primitive.getAsBoolean() ? 1 : 0)); + if (primitive.isString()) return StringNBT.valueOf(primitive.getAsString()); String number = primitive.getAsString(); if (number.contains(".") || number.contains("e") || number.contains("E")) { - return new DoubleNBT(primitive.getAsDouble()); + return DoubleNBT.valueOf(primitive.getAsDouble()); } long value = primitive.getAsLong(); return value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE - ? new IntNBT((int) value) : new LongNBT(value); + ? IntNBT.valueOf((int) value) : LongNBT.valueOf(value); } } 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 4eb9ef8..9c99a3c 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 @@ -350,7 +350,7 @@ private Map witness(ServerWorld world, int[] coordinates) { result.put("pos", coordinates); BlockPos pos = position(coordinates); // No world.getChunk/getTileEntity call is permitted before this non-loading lookup. - Chunk chunk = world.getChunkProvider().func_225313_a(pos.getX() >> 4, pos.getZ() >> 4); + Chunk chunk = world.getChunkProvider().getChunkWithoutLoading(pos.getX() >> 4, pos.getZ() >> 4); result.put("loaded", chunk != null); if (chunk == null) return result; result.put("block", chunk.getBlockState(pos).getBlock().getRegistryName().toString()); @@ -415,12 +415,13 @@ private void snapshot(ServerWorld world, String label) { ChunkPos pos = new ChunkPos(key); Map entry = new LinkedHashMap<>(); entry.put("pos", Arrays.asList(pos.x, pos.z)); - entry.put("loaded", world.getChunkProvider().func_225313_a(pos.x, pos.z) != null); + entry.put("loaded", world.getChunkProvider().getChunkWithoutLoading(pos.x, pos.z) != null); entry.put("unloads", unloads.getOrDefault(key, 0)); entry.put("loads", loads.getOrDefault(key, 0)); chunkStates.add(entry); } result.put("worldTime", world.getGameTime()); + result.put("ticketGeometry", NativeTicketGeometry.observe(world)); result.put("cases", cases); result.put("chunks", chunkStates); result.put("movements", movements); 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 new file mode 100644 index 0000000..d48ed68 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java @@ -0,0 +1,233 @@ +package zone.moddev.mc.advantageworks; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.arguments.StringArgumentType; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.UUID; +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.command.Commands; +import net.minecraft.enchantment.Enchantments; +import net.minecraft.entity.item.ItemEntity; +import net.minecraft.entity.player.ServerPlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.network.IPacket; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.PacketDirection; +import net.minecraft.network.play.ServerPlayNetHandler; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.state.BooleanProperty; +import net.minecraft.util.Hand; +import net.minecraft.util.Direction; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.shapes.IBooleanFunction; +import net.minecraft.util.math.shapes.VoxelShapes; +import net.minecraft.world.GameType; +import net.minecraft.world.server.ServerWorld; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.common.ToolType; +import net.minecraftforge.fml.event.server.FMLServerStartingEvent; +import net.minecraftforge.registries.ForgeRegistries; + +/** Actual survival harvesting, not loot-JSON presence or a synthetic getDrops call. */ +final class NativeHarvestProbe { + private static final File MARKER = new File("advantage-native-harvest.allow"); + private static final BlockPos POS = new BlockPos(1200, 8, 1024); + + static void register() { + if (MARKER.isFile()) MinecraftForge.EVENT_BUS.addListener(new NativeHarvestProbe()::starting); + } + + private void starting(FMLServerStartingEvent event) { + event.getCommandDispatcher().register(Commands.literal("advharvest") + .requires(source -> source.hasPermissionLevel(4)) + .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { + WorksController.getInstance().requireAuthorizedWorld(context.getSource().getServer()); + try { run(context.getSource().getWorld(), StringArgumentType.getString(context, "label")); } + catch (Exception error) { throw new IllegalStateException("Native harvest witness failed", error); } + return 1; + }))); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private static void witness(ServerWorld world, ServerPlayerEntity player, JsonArray rows, String id, + String blockId, int level, Item toolItem, boolean silk, boolean allowed, + String dropId, int minimum, int maximum, int trials) { + witness(world, player, rows, id, blockId, level, toolItem, silk, allowed, dropId, minimum, maximum, trials, -1); + } + + private static void witness(ServerWorld world, ServerPlayerEntity player, JsonArray rows, String id, + String blockId, int level, Item toolItem, boolean silk, boolean allowed, + String dropId, int minimum, int maximum, int trials, int connectionMask) { + JsonObject row = new JsonObject(); + row.addProperty("id", id); + JsonArray samples = new JsonArray(); + JsonArray dropEvidence = new JsonArray(); + row.add("dropEvidence", dropEvidence); + try { + ResourceLocation key = new ResourceLocation(blockId); + require(ForgeRegistries.BLOCKS.containsKey(key), "Missing real block: " + blockId); + Block block = ForgeRegistries.BLOCKS.getValue(key); + player.interactionManager.setGameType(GameType.SURVIVAL); + for (int trial = 0; trial < trials; trial++) { + JsonObject evidence = new JsonObject(); + evidence.addProperty("trial", trial); + evidence.addProperty("worldTime", world.getGameTime()); + List indexed = world.getEntitiesWithinAABB(ItemEntity.class, new AxisAlignedBB(POS).grow(2)); + evidence.addProperty("removedBeforeCleanup", indexed.stream().filter(entity -> entity.removed).count()); + evidence.addProperty("liveBeforeCleanup", indexed.stream().filter(entity -> !entity.removed).count()); + dropEvidence.add(evidence); + harvestDrops(world, new AxisAlignedBB(POS).grow(2)).forEach(ItemEntity::remove); + int remaining = harvestDrops(world, new AxisAlignedBB(POS).grow(2)).size(); + evidence.addProperty("liveAfterCleanup", remaining); + require(remaining == 0, "Harvest fixture retained a live item before placement"); + world.setBlockState(POS, net.minecraft.block.Blocks.AIR.getDefaultState(), 2); + BlockState requested = block.getDefaultState(); + if (connectionMask >= 0) { + int bit = 0; + for (Direction direction : Direction.values()) { + net.minecraft.state.IProperty property = block.getStateContainer().getProperty(direction.getName()); + require(property instanceof BooleanProperty, "Missing inherited conduit connection state"); + boolean connected = (connectionMask & (1 << bit++)) != 0; + requested = requested.with((BooleanProperty) property, connected); + world.setBlockState(POS.offset(direction), connected ? block.getDefaultState() + : net.minecraft.block.Blocks.AIR.getDefaultState(), 2); + } + } + require(world.setBlockState(POS, requested, 2), "Cannot place harvest witness"); + BlockState state = world.getBlockState(POS); + require(state.equals(requested), "Native connection state changed before mining"); + boolean expectedSolid = blockId.endsWith("_ore") || blockId.equals("electricadvantage:electric_conduit"); + require(state.isSolid() == expectedSolid, "Legacy solid/non-solid property changed"); + row.addProperty("actualSolid", state.isSolid()); + boolean fullCollision = !VoxelShapes.compare(state.getCollisionShape(world, POS), + VoxelShapes.fullCube(), IBooleanFunction.NOT_SAME); + require(fullCollision != blockId.equals("electricadvantage:electric_conduit"), + "Legacy wire/frame/scaffold collision geometry changed"); + row.addProperty("fullCollision", fullCollision); + if (level >= 0) require(state.getHarvestTool() == ToolType.PICKAXE + && state.getHarvestLevel() == level, "Historical harvest level/tool changed"); + ItemStack tool = toolItem == null ? ItemStack.EMPTY : new ItemStack(toolItem); + if (silk) tool.addEnchantment(Enchantments.SILK_TOUCH, 1); + player.setHeldItem(Hand.MAIN_HAND, tool); + require(state.canHarvestBlock(world, POS, player) == allowed, "Tool failure/success gate changed"); + require(player.interactionManager.tryHarvestBlock(POS) && world.isAirBlock(POS), + "Native PlayerInteractionManager did not remove the block"); + List drops = harvestDrops(world, new AxisAlignedBB(POS).grow(2)); + JsonArray actualDrops = new JsonArray(); + for (ItemEntity entity : drops) { + JsonObject actual = new JsonObject(); + actual.addProperty("entityId", entity.getEntityId()); + actual.addProperty("uuid", entity.getUniqueID().toString()); + actual.addProperty("item", entity.getItem().getItem().getRegistryName().toString()); + actual.addProperty("count", entity.getItem().getCount()); + actual.addProperty("removed", entity.removed); + actualDrops.add(actual); + } + evidence.add("actualDrops", actualDrops); + int count = 0; + for (ItemEntity entity : drops) { + ItemStack stack = entity.getItem(); + require(stack.getItem().getRegistryName().toString().equals(dropId), "Wrong actual drop: " + stack); + count += stack.getCount(); + } + require(count >= minimum && count <= maximum, "Actual drop quantity changed: " + count); + samples.add(count); + } + row.addProperty("status", "PASS"); + } catch (AssertionError | RuntimeException error) { + row.addProperty("status", "FAIL"); + row.addProperty("failure", error.toString()); + } + row.addProperty("block", blockId); + row.addProperty("historicalHarvestLevel", level); + row.addProperty("harvestAllowed", allowed); + row.addProperty("silkTouch", silk); + row.addProperty("trials", trials); + row.addProperty("connectionMask", connectionMask); + row.add("actualDropCounts", samples); + rows.add(row); + } + + private static void run(ServerWorld world, String label) throws Exception { + require(MARKER.isFile() && label.matches("[A-Za-z0-9_-]+"), "Unmarked harvest runtime or unsafe label"); + File output = new File("advantage-works-results", "native-harvest-" + label + ".json"); + require(!output.exists(), "Refusing to overwrite harvest evidence"); + ServerPlayerEntity player = new ServerPlayerEntity(world.getServer(), world, + new GameProfile(UUID.fromString("c791aefe-a372-4536-9251-27032c01f115"), "AdvWorksHarvest"), + new PlayerInteractionManager(world)); + player.connection = new ServerPlayNetHandler(world.getServer(), new NetworkManager(PacketDirection.SERVERBOUND), player) { + @Override public void sendPacket(IPacket packet) {} + }; + player.setPosition(POS.getX() + 0.5, POS.getY() + 1, POS.getZ() + 0.5); + JsonArray rows = new JsonArray(); + String sulfur = "electricadvantage:sulfur_ore", lithium = "electricadvantage:li_ore"; + witness(world, player, rows, "sulfur/hand-fails", sulfur, 0, null, false, false, "", 0, 0, 1); + witness(world, player, rows, "sulfur/axe-fails", sulfur, 0, Items.IRON_AXE, false, false, "", 0, 0, 1); + witness(world, player, rows, "sulfur/wood", sulfur, 0, Items.WOODEN_PICKAXE, false, true, "electricadvantage:sulfur_powder", 1, 4, 8); + witness(world, player, rows, "sulfur/stone", sulfur, 0, Items.STONE_PICKAXE, false, true, "electricadvantage:sulfur_powder", 1, 4, 8); + witness(world, player, rows, "sulfur/silk", sulfur, 0, Items.WOODEN_PICKAXE, true, true, sulfur, 1, 1, 1); + witness(world, player, rows, "lithium/hand-fails", lithium, 1, null, false, false, "", 0, 0, 1); + witness(world, player, rows, "lithium/wood-fails", lithium, 1, Items.WOODEN_PICKAXE, false, false, "", 0, 0, 1); + witness(world, player, rows, "lithium/gold-fails", lithium, 1, Items.GOLDEN_PICKAXE, false, false, "", 0, 0, 1); + witness(world, player, rows, "lithium/axe-fails", lithium, 1, Items.IRON_AXE, false, false, "", 0, 0, 1); + witness(world, player, rows, "lithium/stone", lithium, 1, Items.STONE_PICKAXE, false, true, lithium, 1, 1, 1); + witness(world, player, rows, "lithium/diamond", lithium, 1, Items.DIAMOND_PICKAXE, false, true, lithium, 1, 1, 1); + witness(world, player, rows, "lithium/silk", lithium, 1, Items.STONE_PICKAXE, true, true, lithium, 1, 1, 1); + witness(world, player, rows, "frame/hand", "poweradvantage:steel_frame", -1, null, false, true, "poweradvantage:steel_frame", 1, 1, 1); + witness(world, player, rows, "frame/silk", "poweradvantage:steel_frame", -1, Items.DIAMOND_PICKAXE, true, true, "poweradvantage:steel_frame", 1, 1, 1); + for (int mask = 0; mask < 64; mask++) { + for (String family : new String[] {"wire", "scaffold"}) { + String identity = "electricadvantage:" + (family.equals("wire") ? "electric_conduit" : "electric_track"); + String prefix = family + "/" + mask; + witness(world, player, rows, prefix + "/hand", identity, -1, null, false, true, identity, 1, 1, 1, mask); + witness(world, player, rows, prefix + "/silk", identity, -1, Items.DIAMOND_PICKAXE, true, true, identity, 1, 1, 1, mask); + } + } + JsonObject solar = new JsonObject(); + solar.addProperty("id", "solar/native-solidity"); + try { + Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation("electricadvantage:photovoltaic_generator")); + require(block != null && block != net.minecraft.block.Blocks.AIR, "Missing native photovoltaic block"); + world.setBlockState(POS, block.getDefaultState(), 2); + require(world.getBlockState(POS).isSolid(), "Legacy photovoltaic solid=true was lost"); + solar.addProperty("actualSolid", true); + solar.addProperty("status", "PASS"); + } catch (AssertionError | RuntimeException error) { + solar.addProperty("status", "FAIL"); solar.addProperty("failure", error.toString()); + } + rows.add(solar); + world.setBlockState(POS, net.minecraft.block.Blocks.AIR.getDefaultState(), 2); + for (Direction direction : Direction.values()) world.setBlockState(POS.offset(direction), net.minecraft.block.Blocks.AIR.getDefaultState(), 2); + harvestDrops(world, new AxisAlignedBB(POS).grow(2)).forEach(ItemEntity::remove); + JsonObject report = new JsonObject(); + report.addProperty("label", label); + report.addProperty("minecraft", "1.15.2"); + report.addProperty("worldTime", world.getGameTime()); + report.addProperty("fixtureIsolation", "native115-live-drops-v1"); + report.addProperty("status", java.util.stream.StreamSupport.stream(rows.spliterator(), false) + .allMatch(row -> "PASS".equals(row.getAsJsonObject().get("status").getAsString())) ? "PASS" : "FAIL"); + report.add("checks", rows); + output.getParentFile().mkdirs(); + Files.write(output.toPath(), new GsonBuilder().setPrettyPrinting().create().toJson(report).getBytes(StandardCharsets.UTF_8)); + System.out.println("ADVHARVEST COMPLETE " + label); + } + + static List harvestDrops(net.minecraft.world.IEntityReader world, AxisAlignedBB bounds) { + // remove() only marks an entity; the chunk index can retain it until the next tick. + return world.getEntitiesWithinAABB(ItemEntity.class, bounds, entity -> !entity.removed); + } +} 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 new file mode 100644 index 0000000..ae8402f --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeTicketGeometry.java @@ -0,0 +1,66 @@ +package zone.moddev.mc.advantageworks; + +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import net.minecraft.world.chunk.ChunkStatus; +import net.minecraft.world.server.ChunkHolder; +import net.minecraft.world.server.ChunkManager; +import net.minecraft.world.server.Ticket; +import net.minecraft.world.server.TicketManager; +import net.minecraft.world.server.TicketType; +import net.minecraft.world.server.ServerWorld; + +/** Read-only engine observations; never adds tickets or loads witness chunks. */ +final class NativeTicketGeometry { + private NativeTicketGeometry() {} + + private static Object uniqueField(Object owner, Class declaring, Class type) throws Exception { + Field selected = null; + for (Field field : declaring.getDeclaredFields()) { + if (type.isAssignableFrom(field.getType())) { + if (selected != null) throw new IllegalStateException("Ambiguous native ticket field"); + selected = field; + } + } + if (selected == null) throw new IllegalStateException("Missing native ticket field"); + selected.setAccessible(true); + return selected.get(owner); + } + + static Map observe(ServerWorld world) { + try { + int fullLevel = -1, entityTickingLevel = -1; + for (int level = 0; level <= ChunkManager.MAX_LOADED_LEVEL; level++) { + if (ChunkHolder.getChunkStatusFromLevel(level) == ChunkStatus.FULL) fullLevel = level; + if (ChunkHolder.getLocationTypeFromLevel(level) == ChunkHolder.LocationType.ENTITY_TICKING) + entityTickingLevel = level; + } + Object provider = world.getChunkProvider(); + Object manager = uniqueField(provider, provider.getClass(), TicketManager.class); + Long2ObjectOpenHashMap tickets = (Long2ObjectOpenHashMap) uniqueField( + manager, TicketManager.class, Long2ObjectOpenHashMap.class); + Set forcedLevels = new TreeSet<>(); + for (Object value : tickets.values()) { + for (Object entry : (Iterable) value) { + Ticket ticket = (Ticket) entry; + if (ticket.getType() == TicketType.FORCED) forcedLevels.add(ticket.getLevel()); + } + } + Map result = new LinkedHashMap<>(); + result.put("fullLevel", fullLevel); + result.put("entityTickingLevel", entityTickingLevel); + result.put("maxDistance", ChunkStatus.maxDistance()); + result.put("lastHolderLevel", ChunkManager.MAX_LOADED_LEVEL); + result.put("holderRadius", ChunkManager.MAX_LOADED_LEVEL - entityTickingLevel); + result.put("observedForcedLevels", new ArrayList<>(forcedLevels)); + return result; + } catch (Exception error) { + throw new IllegalStateException("Cannot observe native Forge 31 ticket geometry", error); + } + } +} 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 4c30fa9..0f38a16 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 @@ -229,8 +229,7 @@ private static TileEntity fresh(ServerWorld world, JsonObject fixture, BlockPos // the previous witness. Give each bounded fixture a clean native tile instance. TileEntity clean = material.createTileEntity(material.getDefaultState(), world); if (clean != null) { - clean.setWorld(world); - clean.setPos(pos); + clean.setWorldAndPos(world, pos); world.setTileEntity(pos, clean); } return clean; 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 19f8646..703bd9a 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 @@ -36,6 +36,8 @@ import net.minecraft.world.chunk.Chunk; import net.minecraft.world.dimension.DimensionType; import net.minecraftforge.common.BiomeDictionary; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.TickEvent; import net.minecraftforge.fml.ModList; import net.minecraftforge.registries.ForgeRegistries; import net.minecraft.fluid.Fluid; @@ -64,6 +66,11 @@ final class WorksController { private final Map statuses = new LinkedHashMap<>(); private final WorksResultWriter resultWriter = new WorksResultWriter(); private String activeExclusiveStation; + private final WorldgenSamplingJob.Slot worldgenJob = new WorldgenSamplingJob.Slot(); + + private WorksController() { + MinecraftForge.EVENT_BUS.addListener(this::worldgenTick); + } static WorksController getInstance() { return INSTANCE; @@ -203,122 +210,67 @@ synchronized String sampleWorldgen(MinecraftServer server, int radius) throws Co if (radius < 1 || radius > 16) { throw commandError("Worldgen sample radius must be between 1 and 16 chunks"); } + if (worldgenJob.current() != null) throw commandError("A worldgen sample is already running"); ServerWorld world = overworld(server); - Map expectedBiomes = new LinkedHashMap<>(); - expectedBiomes.put("poweradvantage:crude_oil", "DESERT"); - expectedBiomes.put("mineralogy:crude_oil", "OCEAN"); - expectedBiomes.put("electricadvantage:li_ore", null); - expectedBiomes.put("electricadvantage:sulfur_ore", null); - expectedBiomes.put("mineralogy:sulfur_ore", null); - - Set chunks = new LinkedHashSet<>(); - Map centers = new LinkedHashMap<>(); BlockPos spawn = world.getSpawnPoint(); - addChunkSquare(chunks, spawn.getX() >> 4, spawn.getZ() >> 4, radius); - centers.put("spawn", (spawn.getX() >> 4) + "," + (spawn.getZ() >> 4)); - addBiomeSample(world, chunks, centers, BiomeDictionary.Type.getType("SANDY"), "DESERT", radius, - BiomeDictionary.Type.getType("BEACH"), BiomeDictionary.Type.getType("MESA")); - addBiomeSample(world, chunks, centers, BiomeDictionary.Type.getType("OCEAN"), "OCEAN", radius); - - Map counts = new LinkedHashMap<>(); - Map violations = new LinkedHashMap<>(); - Map boundaryBlocks = new LinkedHashMap<>(); - for (String id : expectedBiomes.keySet()) { - counts.put(id, 0L); - if (expectedBiomes.get(id) != null) { - violations.put(id, 0L); - boundaryBlocks.put(id, 0L); + worldgenJob.start(new WorldgenSamplingJob(new NativeWorldgenAccess(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; + WorldgenSamplingJob job = worldgenJob.current(); + job.tick(); + if (!job.finished()) return; + try { + if (job.failure() != null) throw job.failure(); + Map payload = job.result(); + File result = resultWriter.write("worldgen", "sample", payload); + org.apache.logging.log4j.LogManager.getLogger(AdvantageWorksMod.MODID).info( + "Worldgen sample wrote {} chunks to {}: {}", payload.get("chunksScanned"), + result.getPath(), payload.get("counts")); + } catch (RuntimeException failure) { + org.apache.logging.log4j.LogManager.getLogger(AdvantageWorksMod.MODID) + .error("Worldgen sample failed", failure); + Map payload = new LinkedHashMap<>(); + payload.put("phase", job.phase().name()); + payload.put("failure", failure.toString()); + try { + resultWriter.write("worldgen", "sample-failed", payload); + } catch (RuntimeException writeFailure) { + org.apache.logging.log4j.LogManager.getLogger(AdvantageWorksMod.MODID) + .error("Cannot publish worldgen sample failure", writeFailure); } + } finally { + worldgenJob.release(); } + } - // Populate a halo first; large imported OS1 clusters can cross a sampled chunk boundary. - Set populationChunks = new LinkedHashSet<>(); - for (Long packed : chunks) { - int chunkX = (int) (packed >> 32); - int chunkZ = (int) (long) packed; - addChunkSquare(populationChunks, chunkX, chunkZ, 2); - } - for (Long packed : populationChunks) { - int chunkX = (int) (packed >> 32); - int chunkZ = (int) (long) packed; - world.getChunk(chunkX, chunkZ); + private static final class NativeWorldgenAccess implements WorldgenSamplingJob.WorldAccess { + private final ServerWorld world; + private final BlockPos.Mutable cursor = new BlockPos.Mutable(); + + NativeWorldgenAccess(ServerWorld world) { this.world = world; } + + @Override public boolean matchesBiome(String expected, int x, int y, int z) { + return matchesExpectedBiome(expected, world.getBiome(cursor.setPos(x, y, z))); } - BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); - for (Long packed : chunks) { - int chunkX = (int) (packed >> 32); - int chunkZ = (int) (long) packed; - Chunk chunk = world.getChunk(chunkX, chunkZ); - int minX = chunkX << 4; - int minZ = chunkZ << 4; - Biome chunkCenterBiome = world.getBiome( - new BlockPos(minX + 8, world.getSeaLevel(), minZ + 8)); - for (int localX = 0; localX < 16; localX++) { - for (int localZ = 0; localZ < 16; localZ++) { - cursor.setPos(minX + localX, 0, minZ + localZ); - for (int y = 0; y <= 96; y++) { - cursor.setY(y); - ResourceLocation id = ForgeRegistries.BLOCKS.getKey( - chunk.getBlockState(cursor).getBlock()); - String name = id == null ? null : id.toString(); - if (name == null || !counts.containsKey(name)) continue; - counts.put(name, counts.get(name) + 1L); - String expectedBiome = expectedBiomes.get(name); - if (expectedBiome != null && !matchesExpectedBiome(expectedBiome, chunkCenterBiome)) { - violations.put(name, violations.get(name) + 1L); - } else if (expectedBiome != null - && !matchesExpectedBiome(expectedBiome, world.getBiome(cursor))) { - boundaryBlocks.put(name, boundaryBlocks.get(name) + 1L); - } - } - } - } - } + @Override public void populateChunk(int chunkX, int chunkZ) { world.getChunk(chunkX, chunkZ); } - Map payload = new LinkedHashMap<>(); - payload.put("radius", radius); - payload.put("chunksScanned", chunks.size()); - payload.put("centers", centers); - payload.put("counts", counts); - payload.put("biomeViolations", violations); - payload.put("biomeBoundaryBlocks", boundaryBlocks); - File result = resultWriter.write("worldgen", "sample", payload); - return "Worldgen sample wrote " + chunks.size() + " chunks to " + result.getPath() - + ": " + counts; - } - - private static void addBiomeSample(ServerWorld world, Set chunks, - Map centers, - BiomeDictionary.Type type, String label, int radius, - BiomeDictionary.Type... excludedTypes) { - BlockPos origin = world.getSpawnPoint(); - BlockPos located = null; - double bestDistance = Double.POSITIVE_INFINITY; - int originChunkX = origin.getX() >> 4; - int originChunkZ = origin.getZ() >> 4; - for (int offsetZ = -256; offsetZ <= 256; offsetZ += 4) { - for (int offsetX = -256; offsetX <= 256; offsetX += 4) { - BlockPos candidate = new BlockPos( - (originChunkX + offsetX) << 4, world.getSeaLevel(), - (originChunkZ + offsetZ) << 4); - Biome biome = world.getBiome(candidate); - if (!BiomeDictionary.hasType(biome, type) - || hasAnyBiomeType(biome, excludedTypes)) continue; - double distance = candidate.distanceSq(origin); - if (distance < bestDistance) { - located = candidate; - bestDistance = distance; + @Override public WorldgenSamplingJob.SampleChunk sampleChunk(int chunkX, int chunkZ) { + Chunk chunk = world.getChunk(chunkX, chunkZ); + Biome center = world.getBiome(new BlockPos((chunkX << 4) + 8, world.getSeaLevel(), (chunkZ << 4) + 8)); + return new WorldgenSamplingJob.SampleChunk() { + @Override public boolean centerMatches(String expected) { return matchesExpectedBiome(expected, center); } + @Override public String blockName(int x, int y, int z) { + ResourceLocation id = ForgeRegistries.BLOCKS.getKey( + chunk.getBlockState(cursor.setPos(x, y, z)).getBlock()); + return id == null ? null : id.toString(); } - } - } - if (located == null) { - centers.put(label, "NOT_FOUND"); - return; + }; } - int chunkX = located.getX() >> 4; - int chunkZ = located.getZ() >> 4; - centers.put(label, chunkX + "," + chunkZ); - addChunkSquare(chunks, chunkX, chunkZ, radius); } private static boolean matchesExpectedBiome(String expected, Biome biome) { @@ -330,20 +282,6 @@ private static boolean matchesExpectedBiome(String expected, Biome biome) { return BiomeDictionary.hasType(biome, BiomeDictionary.Type.getType(expected)); } - private static boolean hasAnyBiomeType(Biome biome, BiomeDictionary.Type... types) { - for (BiomeDictionary.Type type : types) { - if (BiomeDictionary.hasType(biome, type)) return true; - } - return false; - } - - private static void addChunkSquare(Set chunks, int centerX, int centerZ, int radius) { - for (int chunkZ = centerZ - radius; chunkZ <= centerZ + radius; chunkZ++) { - for (int chunkX = centerX - radius; chunkX <= centerX + radius; chunkX++) { - chunks.add((((long) chunkX) << 32) ^ (chunkZ & 0xffffffffL)); - } - } - } List stationIdsWithAll() { loadDefinition(); List ids = new ArrayList<>(); 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 e011ba0..f6c6254 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 @@ -15,7 +15,7 @@ import java.util.Set; final class WorksDefinitionLoader { - private static final String RESOURCE = "/assets/advantageworkstest/works/works-1.14.4.json"; + private static final String RESOURCE = "/assets/advantageworkstest/works/works-1.15.2.json"; private static final String STATION_SOURCE = "/assets/advantageworkstest/works/works-1.13.2.json"; private static final Gson GSON = new GsonBuilder().create(); private static final Set ACTION_TYPES = new HashSet<>(Arrays.asList( @@ -31,8 +31,12 @@ private WorksDefinitionLoader() { } static WorksDefinition load() { - JsonObject target = readResource(RESOURCE); - if (target.get("format").getAsInt() != 1 || !"1.14.4".equals(target.get("minecraft").getAsString()) + return load(RESOURCE, "1.15.2"); + } + + static WorksDefinition load(String resource, String minecraft) { + JsonObject target = readResource(resource); + if (target.get("format").getAsInt() != 1 || !minecraft.equals(target.get("minecraft").getAsString()) || !STATION_SOURCE.equals(target.get("stationSource").getAsString())) throw new IllegalArgumentException("Unsupported native works descriptor"); JsonObject stations = readResource(STATION_SOURCE); @@ -41,7 +45,7 @@ static WorksDefinition load() { stations.add("minecraft", target.get("minecraft")); stations.add("title", target.get("title")); WorksDefinition definition = GSON.fromJson(stations, WorksDefinition.class); - validate(definition); + validate(definition, minecraft); return definition; } @@ -55,8 +59,8 @@ private static JsonObject readResource(String resource) { } } - private static void validate(WorksDefinition definition) { - if (definition == null || definition.format != 1 || !"1.14.4".equals(definition.minecraft)) { + private static void validate(WorksDefinition definition, String minecraft) { + if (definition == null || definition.format != 1 || !minecraft.equals(definition.minecraft)) { throw new IllegalArgumentException("Unsupported or missing works definition header"); } Set districtIds = new HashSet<>(); 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 f300fd9..4ff122b 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", "1.14.4"); + document.put("minecraft", "1.15.2"); document.put("forge", net.minecraftforge.versions.forge.ForgeVersion.getVersion()); document.put("mods", loadedMods()); document.put("station", stationId); diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorldgenSamplingJob.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorldgenSamplingJob.java new file mode 100644 index 0000000..02a55d2 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorldgenSamplingJob.java @@ -0,0 +1,211 @@ +package zone.moddev.mc.advantageworks; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** The original sample order, with a bounded batch and a return to the server between phases. */ +final class WorldgenSamplingJob { + static final int SEARCH_BATCH = 256; + static final int PLAN_BATCH = 64; + static final int POPULATION_BATCH = 4; + static final int COUNT_BATCH = 2; + private static final int SEARCH_SIDE = 129; + + interface WorldAccess { + boolean matchesBiome(String expected, int x, int y, int z); + void populateChunk(int chunkX, int chunkZ); + SampleChunk sampleChunk(int chunkX, int chunkZ); + } + + interface SampleChunk { + boolean centerMatches(String expected); + String blockName(int x, int y, int z); + } + + enum Phase { SPAWN, DESERT, DESERT_SQUARE, OCEAN, OCEAN_SQUARE, HALO, POPULATE, COUNT, DONE, FAILED } + + static final class Slot { + private WorldgenSamplingJob active; + void start(WorldgenSamplingJob job) { + if (active != null) throw new IllegalStateException("A worldgen sample is already running"); + active = job; + } + WorldgenSamplingJob current() { return active; } + void release() { active = null; } + } + + private final WorldAccess world; + private final int spawnX, spawnY, spawnZ, seaLevel, radius; + private final Set chunks = new LinkedHashSet<>(); + private final Set populationChunks = new LinkedHashSet<>(); + private final Map expectedBiomes = new LinkedHashMap<>(); + private final Map centers = new LinkedHashMap<>(); + private final Map counts = new LinkedHashMap<>(); + private final Map violations = new LinkedHashMap<>(); + private final Map boundaryBlocks = new LinkedHashMap<>(); + private Phase phase = Phase.SPAWN; + private int squareX, squareZ, squareIndex, searchIndex; + private int locatedX, locatedZ; + private boolean located; + private double bestDistance = Double.POSITIVE_INFINITY; + private Iterator iterator; + private RuntimeException failure; + + WorldgenSamplingJob(WorldAccess world, int spawnX, int spawnY, int spawnZ, int seaLevel, int radius) { + if (radius < 1 || radius > 16) throw new IllegalArgumentException("Worldgen radius must be 1..16"); + this.world = world; + this.spawnX = spawnX; + this.spawnY = spawnY; + this.spawnZ = spawnZ; + this.seaLevel = seaLevel; + this.radius = radius; + squareX = spawnX >> 4; + squareZ = spawnZ >> 4; + centers.put("spawn", squareX + "," + squareZ); + expectedBiomes.put("poweradvantage:crude_oil", "DESERT"); + expectedBiomes.put("mineralogy:crude_oil", "OCEAN"); + expectedBiomes.put("electricadvantage:li_ore", null); + expectedBiomes.put("electricadvantage:sulfur_ore", null); + expectedBiomes.put("mineralogy:sulfur_ore", null); + for (String id : expectedBiomes.keySet()) { + counts.put(id, 0L); + if (expectedBiomes.get(id) != null) { + violations.put(id, 0L); + boundaryBlocks.put(id, 0L); + } + } + } + + void tick() { + if (finished()) return; + try { + switch (phase) { + case SPAWN: addSquare(Phase.DESERT); break; + case DESERT: search("DESERT", Phase.DESERT_SQUARE, Phase.OCEAN); break; + case DESERT_SQUARE: addSquare(Phase.OCEAN); break; + case OCEAN: search("OCEAN", Phase.OCEAN_SQUARE, Phase.HALO); break; + case OCEAN_SQUARE: addSquare(Phase.HALO); break; + case HALO: planHalo(); break; + case POPULATE: populate(); break; + case COUNT: count(); break; + default: break; + } + } catch (RuntimeException exception) { + failure = exception; + phase = Phase.FAILED; + } + } + + Phase phase() { return phase; } + boolean finished() { return phase == Phase.DONE || phase == Phase.FAILED; } + RuntimeException failure() { return failure; } + + Map result() { + if (phase != Phase.DONE) throw new IllegalStateException("Worldgen sample is not complete", failure); + Map payload = new LinkedHashMap<>(); + payload.put("radius", radius); + payload.put("chunksScanned", chunks.size()); + payload.put("centers", centers); + payload.put("counts", counts); + payload.put("biomeViolations", violations); + payload.put("biomeBoundaryBlocks", boundaryBlocks); + return payload; + } + + private void addSquare(Phase next) { + int side = radius * 2 + 1; + for (int work = 0; work < SEARCH_BATCH && squareIndex < side * side; work++, squareIndex++) { + chunks.add(pack(squareX - radius + squareIndex % side, squareZ - radius + squareIndex / side)); + } + if (squareIndex == side * side) { + squareIndex = 0; + phase = next; + } + } + + private void search(String expected, Phase found, Phase absent) { + for (int work = 0; work < SEARCH_BATCH && searchIndex < SEARCH_SIDE * SEARCH_SIDE; + work++, searchIndex++) { + int x = ((spawnX >> 4) - 256 + searchIndex % SEARCH_SIDE * 4) << 4; + int z = ((spawnZ >> 4) - 256 + searchIndex / SEARCH_SIDE * 4) << 4; + if (!world.matchesBiome(expected, x, seaLevel, z)) continue; + // Vec3i.distanceSq(Vec3i) uses the candidate block's center in the mapped native API. + double dx = x + 0.5D - spawnX, dy = seaLevel + 0.5D - spawnY, dz = z + 0.5D - spawnZ; + double distance = dx * dx + dy * dy + dz * dz; + if (distance < bestDistance) { + located = true; + locatedX = x >> 4; + locatedZ = z >> 4; + bestDistance = distance; + } + } + if (searchIndex == SEARCH_SIDE * SEARCH_SIDE) { + centers.put(expected, located ? locatedX + "," + locatedZ : "NOT_FOUND"); + squareX = locatedX; + squareZ = locatedZ; + phase = located ? found : absent; + searchIndex = 0; + located = false; + bestDistance = Double.POSITIVE_INFINITY; + } + } + + private void planHalo() { + if (iterator == null) iterator = chunks.iterator(); + for (int work = 0; work < PLAN_BATCH && iterator.hasNext(); work++) { + long packed = iterator.next(); + int x = (int) (packed >> 32), z = (int) packed; + for (int haloZ = z - 2; haloZ <= z + 2; haloZ++) { + for (int haloX = x - 2; haloX <= x + 2; haloX++) { + populationChunks.add(pack(haloX, haloZ)); + } + } + } + if (!iterator.hasNext()) { + iterator = populationChunks.iterator(); + phase = Phase.POPULATE; + } + } + + private void populate() { + for (int work = 0; work < POPULATION_BATCH && iterator.hasNext(); work++) { + long packed = iterator.next(); + world.populateChunk((int) (packed >> 32), (int) packed); + } + if (!iterator.hasNext()) { + iterator = chunks.iterator(); + phase = Phase.COUNT; + } + } + + private void count() { + for (int work = 0; work < COUNT_BATCH && iterator.hasNext(); work++) { + long packed = iterator.next(); + int chunkX = (int) (packed >> 32), chunkZ = (int) packed; + SampleChunk chunk = world.sampleChunk(chunkX, chunkZ); + int minX = chunkX << 4, minZ = chunkZ << 4; + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + for (int y = 0; y <= 96; y++) { + int x = minX + localX, z = minZ + localZ; + String name = chunk.blockName(x, y, z); + if (name == null || !counts.containsKey(name)) continue; + counts.put(name, counts.get(name) + 1L); + String expected = expectedBiomes.get(name); + if (expected != null && !chunk.centerMatches(expected)) { + violations.put(name, violations.get(name) + 1L); + } else if (expected != null && !world.matchesBiome(expected, x, y, z)) { + boundaryBlocks.put(name, boundaryBlocks.get(name) + 1L); + } + } + } + } + } + if (!iterator.hasNext()) phase = Phase.DONE; + } + + private static long pack(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); } +} diff --git a/test-rig/src/main/resources/META-INF/mods.toml b/test-rig/src/main/resources/META-INF/mods.toml index 2a3648a..4ea5181 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="[28,)" +loaderVersion="[31,)" license="LGPL-3.0-only" [[mods]] @@ -12,20 +12,20 @@ description='''Server-side commissioning harness for the Advantage family. Not f [[dependencies.advantageworkstest]] modId="poweradvantage" mandatory=true -versionRange="[2.4.5.114041,)" +versionRange="[2.4.6.115021,)" ordering="AFTER" side="SERVER" [[dependencies.advantageworkstest]] modId="forge" mandatory=true -versionRange="[28.2.26,)" +versionRange="[31.2.57,)" ordering="NONE" side="SERVER" [[dependencies.advantageworkstest]] modId="minecraft" mandatory=true -versionRange="[1.14.4]" +versionRange="[1.15.2]" ordering="NONE" side="SERVER" diff --git a/test-rig/src/main/resources/assets/advantageworkstest/works/works-1.15.2.json b/test-rig/src/main/resources/assets/advantageworkstest/works/works-1.15.2.json new file mode 100644 index 0000000..1aeed30 --- /dev/null +++ b/test-rig/src/main/resources/assets/advantageworkstest/works/works-1.15.2.json @@ -0,0 +1,6 @@ +{ + "format": 1, + "minecraft": "1.15.2", + "title": "Advantage Works 1.15.2 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 7f03646..a337c19 100644 --- a/test-rig/src/main/resources/pack.mcmeta +++ b/test-rig/src/main/resources/pack.mcmeta @@ -1,6 +1,6 @@ { "pack": { "description": "Advantage Works test resources", - "pack_format": 4 + "pack_format": 5 } } 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 7b3b0b0..c5768bb 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 @@ -31,7 +31,10 @@ import java.util.Map; import java.util.Set; import java.util.TimeZone; +import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import java.util.function.LongSupplier; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.net.URL; @@ -59,9 +62,11 @@ public static void main(String[] args) throws Exception { if (arguments.serverPort != null) profile.serverPort = arguments.serverPort; File testRig = findTestRig(arguments.profile); File powerRoot = testRig.getParentFile().getCanonicalFile(); + // Pin validation precedes directory creation, downloads, and world access. + requireInputPins(profile); if (arguments.validateOnly) { List> inputs = stageMods(testRig, null, profile.mods); - System.out.println("Native 1.14.4 profile preflight only (no world opened): " + GSON.toJson(inputs)); + System.out.println("Native 1.15.2 profile preflight only (no world opened): " + GSON.toJson(inputs)); return; } String stamp = timestamp(); @@ -113,13 +118,16 @@ public static void main(String[] args) throws Exception { } checkStations(first, lifecycleStations, selectedLifecycle); } + long firstShutdownDeadline = 0L; if (profile.worldgenSampleRadius > 0) { Thread.sleep(stationRunSeconds * 1000L); - send(first, "advworks sample-worldgen " + profile.worldgenSampleRadius); + firstShutdownDeadline = worldgenDeadline(profile.shutdownTimeoutSeconds, System.nanoTime()); + awaitWorldgen(first, results, "first-run", profile.worldgenSampleRadius, firstShutdownDeadline); } send(first, "save-all"); send(first, "stop"); - requireExit(first, profile.shutdownTimeoutSeconds); + if (profile.worldgenSampleRadius > 0) requireExitUntil(first, firstShutdownDeadline); + else requireExit(first, profile.shutdownTimeoutSeconds); requireNoCommandFailures(latestLog, "first-run"); copyIfPresent(latestLog, new File(results, "first-run.log")); File firstWorldgen = new File(results, "worldgen-first-run.json"); @@ -144,12 +152,15 @@ public static void main(String[] args) throws Exception { } checkStations(second, lifecycleStations, selectedLifecycle); } + long secondShutdownDeadline = 0L; if (profile.worldgenSampleRadius > 0) { - send(second, "advworks sample-worldgen " + profile.worldgenSampleRadius); + secondShutdownDeadline = worldgenDeadline(profile.shutdownTimeoutSeconds, System.nanoTime()); + awaitWorldgen(second, results, "restart-run", profile.worldgenSampleRadius, secondShutdownDeadline); } send(second, "save-all"); send(second, "stop"); - requireExit(second, profile.shutdownTimeoutSeconds); + if (profile.worldgenSampleRadius > 0) requireExitUntil(second, secondShutdownDeadline); + else requireExit(second, profile.shutdownTimeoutSeconds); requireNoCommandFailures(latestLog, "restart-run"); copyIfPresent(latestLog, new File(results, "restart-run.log")); File restartWorldgen = new File(results, "worldgen-restart-run.json"); @@ -269,6 +280,111 @@ private static void requireExit(Process process, int timeoutSeconds) throws Exce } } + static long worldgenDeadline(int timeoutSeconds, long now) { + if (timeoutSeconds <= 0) throw new IllegalArgumentException("Worldgen completion requires a bounded shutdown budget"); + return now + TimeUnit.SECONDS.toNanos(timeoutSeconds); + } + + private static void requireExitUntil(Process process, long deadline) throws Exception { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0 || !process.waitFor(remaining, TimeUnit.NANOSECONDS)) { + process.destroyForcibly(); + throw new IllegalStateException("Worldgen completion and shutdown exceeded the shared shutdown budget"); + } + if (process.exitValue() != 0) throw new IllegalStateException("Server process failed with exit " + process.exitValue()); + } + + private static void awaitWorldgen(Process process, File results, String phase, int radius, long deadline) + throws Exception { + withWorldgenFailureCleanup(process, deadline, System::nanoTime, () -> { + WorldgenCompletion completion = new WorldgenCompletion(results, phase); + send(process, "advworks sample-worldgen " + radius); + awaitWorldgenCompletion(process::isAlive, completion, deadline, System::nanoTime, () -> Thread.sleep(100L)); + }); + } + + interface WorldgenPause { void pause() throws Exception; } + + static void withWorldgenFailureCleanup(Process process, long deadline, LongSupplier clock, + WorldgenPause request) throws Exception { + try { + request.pause(); + } catch (Exception failure) { + boolean interrupted = failure instanceof InterruptedException || Thread.currentThread().isInterrupted(); + try { + if (process.isAlive()) { + send(process, "stop"); + long remaining = deadline - clock.getAsLong(); + if (!interrupted && !Thread.currentThread().isInterrupted() && remaining > 0) { + process.waitFor(remaining, TimeUnit.NANOSECONDS); + } + } + } catch (Throwable cleanupFailure) { + if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); + if (cleanupFailure instanceof InterruptedException) interrupted = true; + } finally { + try { + if (process.isAlive()) process.destroyForcibly(); + } catch (Throwable cleanupFailure) { + if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); + } finally { + if (interrupted) Thread.currentThread().interrupt(); + } + } + throw failure; + } + } + + static void awaitWorldgenCompletion(BooleanSupplier alive, WorldgenCompletion completion, long deadline, + LongSupplier clock, WorldgenPause pause) throws Exception { + while (true) { + if (clock.getAsLong() >= deadline) { + throw new IllegalStateException("Worldgen sample did not complete within the shared shutdown budget"); + } + if (!alive.getAsBoolean()) throw new IllegalStateException("Server exited before worldgen sample completion"); + if (completion.complete()) return; + pause.pause(); + } + } + + static final class WorldgenCompletion { + final File report; + final File failure; + final File previousReport; + final File previousFailure; + + WorldgenCompletion(File results, String phase) throws Exception { + report = new File(results, "worldgen-sample.json"); + failure = new File(results, "worldgen-sample-failed.json"); + // Removing the old path by archival is the freshness barrier, even for identical counts/timestamps. + previousReport = archive(report, phase); + previousFailure = archive(failure, phase); + } + + boolean complete() throws Exception { + if (failure.isFile()) throw new IllegalStateException("Worldgen sample failed: " + readJsonObject(failure)); + if (!report.isFile()) return false; + JsonObject document = readJsonObject(report); + if (!document.has("format") || document.get("format").getAsInt() != 1 + || !document.has("station") || !"worldgen".equals(document.get("station").getAsString()) + || !document.has("kind") || !"sample".equals(document.get("kind").getAsString())) { + throw new IllegalStateException("Invalid worldgen completion report " + report); + } + for (String metric : Arrays.asList("radius", "chunksScanned", "centers", "counts", + "biomeViolations", "biomeBoundaryBlocks")) { + if (!document.has(metric)) throw new IllegalStateException("Incomplete worldgen report: " + metric); + } + return true; + } + + private static File archive(File file, String phase) throws Exception { + if (!file.exists()) return null; + File archived = new File(file.getParentFile(), file.getName() + ".before-" + phase + "-" + UUID.randomUUID()); + Files.move(file.toPath(), archived.toPath()); + return archived; + } + } + private static void send(Process process, String command) throws Exception { process.getOutputStream().write((command + System.lineSeparator()).getBytes(StandardCharsets.UTF_8)); process.getOutputStream().flush(); @@ -425,7 +541,7 @@ private static File java8Home(Profile profile) throws Exception { } File executable = javaExecutable(home); if (!executable.isFile()) { - throw new IllegalStateException("Java 8 is required for packaged Forge 1.14.4. Set " + throw new IllegalStateException("Java 8 is required for packaged Forge 1.15.2. Set " + "ADVANTAGE_WORKS_JAVA8_HOME; checked " + home); } return home.getCanonicalFile(); @@ -489,7 +605,7 @@ private static void writeRuntimeFiles(File runtime, Profile profile) throws Exce + "level-seed=" + profile.levelSeed + "\n" + "level-type=" + profile.levelType + "\n" + "max-players=8\n" - + "motd=Advantage Works 1.14.4 Commissioning Ground\n" + + "motd=Advantage Works 1.15.2 Commissioning Ground\n" + "online-mode=false\n" + "server-ip=127.0.0.1\n" + "pvp=false\n" @@ -532,20 +648,24 @@ private static void writeRunManifest(File runtime, Profile profile, } static Profile readProfile(File file) throws Exception { + return readProfile(file, "1.15.2", "1.15.2-31.2.57"); + } + + static Profile readProfile(File file, String minecraft, String forge) throws Exception { try (InputStreamReader reader = new InputStreamReader( new FileInputStream(file), StandardCharsets.UTF_8)) { Profile profile = GSON.fromJson(reader, Profile.class); if (profile == null || profile.name == null || profile.mode == null) { throw new IllegalArgumentException("Incomplete profile " + file); } - if (!"1.14.4".equals(profile.minecraftVersion)) { - throw new IllegalArgumentException("Historical source profile, not a native114 destination: " + file); + if (!minecraft.equals(profile.minecraftVersion)) { + throw new IllegalArgumentException("Historical source profile, not a native115 destination: " + file); } if (!"PORTED_UNQUALIFIED".equals(profile.portStatus)) { - throw new IllegalStateException("Native114 profile is not ported: " + profile.portBlocker); + throw new IllegalStateException("Native115 profile is not ported: " + profile.portBlocker); } - if (profile.forge == null || !"1.14.4-28.2.26".equals(profile.forge.version)) { - throw new IllegalArgumentException("Native114 profile must select Forge 1.14.4-28.2.26"); + if (profile.forge == null || !forge.equals(profile.forge.version)) { + throw new IllegalArgumentException("Native115 profile must select Forge 1.15.2-31.2.57"); } if (profile.levelName == null || !profile.levelName.startsWith("AdvantageWorks")) { throw new IllegalArgumentException("Profile levelName must begin with AdvantageWorks: " + file); @@ -554,6 +674,14 @@ static Profile readProfile(File file) throws Exception { } } + static void requireInputPins(Profile profile) { + for (ModInput input : profile.mods) { + if (input.sha256 == null || !input.sha256.matches("[A-Fa-f0-9]{64}")) { + throw new IllegalStateException("Pending native115 SHA-256 pin: " + input.id); + } + } + } + static File findTestRig(File profile) throws Exception { File current = profile.getCanonicalFile().getParentFile(); while (current != null) { @@ -931,13 +1059,13 @@ else if ("--server-port".equals(args[i]) && i + 1 < args.length) { static final class Profile { String name; String mode; - String runtimeBase = "build/works-native114"; + String runtimeBase = "build/works-native115"; int serverPort = 25565; int startupTimeoutSeconds = 120; int shutdownTimeoutSeconds = 60; int stationRunSeconds = 5; String levelName = "AdvantageWorks"; - String levelSeed = "advantage-works-1.14.4-v1"; + String levelSeed = "advantage-works-1.15.2-v1"; String levelType = "FLAT"; String generatorSettings = ""; boolean buildWorks = true; diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native114DefinitionTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native114DefinitionTest.java index d06f08f..cfa97cb 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native114DefinitionTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native114DefinitionTest.java @@ -13,7 +13,8 @@ public void nativeTargetPreservesEverySourceStationAndDistrict() throws Exceptio try (InputStreamReader reader = new InputStreamReader(getClass().getResourceAsStream( "/assets/advantageworkstest/works/works-1.13.2.json"), StandardCharsets.UTF_8)) { WorksDefinition source = gson.fromJson(reader, WorksDefinition.class); - WorksDefinition target = WorksDefinitionLoader.load(); + WorksDefinition target = WorksDefinitionLoader.load( + "/assets/advantageworkstest/works/works-1.14.4.json", "1.14.4"); assertEquals("1.13.2", source.minecraft); assertEquals("1.14.4", target.minecraft); assertEquals(21, target.stations.size()); diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native115DefinitionTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native115DefinitionTest.java new file mode 100644 index 0000000..8671322 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/Native115DefinitionTest.java @@ -0,0 +1,24 @@ +package zone.moddev.mc.advantageworks; + +import com.google.gson.Gson; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class Native115DefinitionTest { + @Test + public void nativeTargetPreservesEverySourceStationAndDistrict() throws Exception { + Gson gson = new Gson(); + try (InputStreamReader reader = new InputStreamReader(getClass().getResourceAsStream( + "/assets/advantageworkstest/works/works-1.13.2.json"), StandardCharsets.UTF_8)) { + WorksDefinition source = gson.fromJson(reader, WorksDefinition.class); + WorksDefinition target = WorksDefinitionLoader.load(); + assertEquals("1.13.2", source.minecraft); + assertEquals("1.15.2", target.minecraft); + assertEquals(21, target.stations.size()); + assertEquals(gson.toJsonTree(source.districts), gson.toJsonTree(target.districts)); + assertEquals(gson.toJsonTree(source.stations), gson.toJsonTree(target.stations)); + } + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeHarvestIsolationTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeHarvestIsolationTest.java new file mode 100644 index 0000000..0cf1ed2 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeHarvestIsolationTest.java @@ -0,0 +1,107 @@ +package zone.moddev.mc.advantageworks; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.ItemEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.util.EntityPredicates; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.registry.Bootstrap; +import net.minecraft.world.IEntityReader; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class NativeHarvestIsolationTest { + private static final AxisAlignedBB BOUNDS = new AxisAlignedBB(0, 0, 0, 1, 1, 1).grow(2); + + @BeforeClass public static void initializeNativeRegistries() { Bootstrap.register(); } + + private static ItemEntity item(Item type, int count) { + return new ItemEntity(null, 0.5, 0.5, 0.5, new ItemStack(type, count)); + } + + @Test public void nativeDefaultQueryReproducesFourThenSixThenEightBeforeTickCleanup() { + QueryReader reader = new QueryReader(); + ItemEntity first = item(Items.GUNPOWDER, 4); + reader.index.add(first); + assertEquals(4, count(reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS))); + reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS).forEach(ItemEntity::remove); + assertTrue(first.removed); + assertTrue(EntityPredicates.NOT_SPECTATING.test(first)); + assertSame(first, reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS).get(0)); + reader.index.add(item(Items.GUNPOWDER, 2)); + assertEquals(6, count(reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS))); + reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS).forEach(ItemEntity::remove); + reader.index.add(item(Items.GUNPOWDER, 2)); + assertEquals(8, count(reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS))); + } + + @Test public void removedEarlierDropCannotInflateTheNextTrial() { + QueryReader reader = new QueryReader(); + reader.index.add(item(Items.GUNPOWDER, 4)); + NativeHarvestProbe.harvestDrops(reader, BOUNDS).forEach(ItemEntity::remove); + assertTrue(NativeHarvestProbe.harvestDrops(reader, BOUNDS).isEmpty()); + reader.index.add(item(Items.GUNPOWDER, 2)); + assertEquals(2, count(NativeHarvestProbe.harvestDrops(reader, BOUNDS))); + } + + @Test public void removedSulfurLikeDropCannotContaminateSilkOrWrongToolCases() { + QueryReader reader = new QueryReader(); + ItemEntity powder = item(Items.GUNPOWDER, 4); + reader.index.add(powder); + powder.remove(); + assertTrue(NativeHarvestProbe.harvestDrops(reader, BOUNDS).isEmpty()); + ItemEntity ore = item(Items.IRON_ORE, 1); + reader.index.add(ore); + assertEquals(Collections.singletonList(ore), NativeHarvestProbe.harvestDrops(reader, BOUNDS)); + } + + @Test public void everyUnexpectedLiveIdentityAndQuantityRemainsVisibleToTheOracle() { + QueryReader reader = new QueryReader(); + ItemEntity expected = item(Items.IRON_ORE, 1), unexpected = item(Items.GUNPOWDER, 5); + reader.index.add(expected); + reader.index.add(unexpected); + assertEquals(reader.index, NativeHarvestProbe.harvestDrops(reader, BOUNDS)); + assertEquals(6, count(NativeHarvestProbe.harvestDrops(reader, BOUNDS))); + } + + @Test public void fixtureQueryKeepsTheOriginalSpatialBoundary() { + QueryReader reader = new QueryReader(); + ItemEntity outside = item(Items.GUNPOWDER, 4); + outside.setPosition(32, 0.5, 0.5); + reader.index.add(outside); + assertTrue(NativeHarvestProbe.harvestDrops(reader, BOUNDS).isEmpty()); + } + + private static int count(List entities) { + return entities.stream().mapToInt(entity -> entity.getItem().getCount()).sum(); + } + + /** Models only Chunk's index and predicate dispatch; native entities/default queries are real. */ + private static final class QueryReader implements IEntityReader { + final List index = new ArrayList<>(); + + @Override public List getEntitiesWithinAABB(Class type, + AxisAlignedBB bounds, Predicate filter) { + List found = new ArrayList<>(); + for (ItemEntity entity : index) if (type.isInstance(entity) && entity.getBoundingBox().intersects(bounds)) { + T value = type.cast(entity); + if (filter == null || filter.test(value)) found.add(value); + } + return found; + } + + @Override public List getEntitiesInAABBexcluding(Entity except, AxisAlignedBB bounds, Predicate filter) { + throw new UnsupportedOperationException("Not used by harvest queries"); + } + + @Override public List getPlayers() { return Collections.emptyList(); } + } +} 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 new file mode 100644 index 0000000..b8355f2 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorldgenProtocolContractTest.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.advantageworks; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import org.junit.Test; +import static org.junit.Assert.*; + +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")); + String command = source.substring(source.indexOf("synchronized String sampleWorldgen("), + source.indexOf("List stationIdsWithAll()")); + assertFalse("The command must not synchronously populate the halo", + command.contains("for (Long packed : populationChunks)")); + } + + @Test public void bothRunsWaitForFreshCompletionBeforeSaveAndStop() throws Exception { + String source = source("src/runner/java/zone/moddev/mc/advantageworks/runner/WorksRunner.java"); + for (String process : new String[] {"first", "second"}) { + int wait = source.indexOf("awaitWorldgen(" + process + ","); + int save = source.indexOf("send(" + process + ", \"save-all\")"); + assertTrue(process + " must await its own new completed report", wait >= 0 && wait < save); + } + } + + private static String source(String path) throws Exception { + return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorldgenSamplingJobTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorldgenSamplingJobTest.java new file mode 100644 index 0000000..147cbea --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorldgenSamplingJobTest.java @@ -0,0 +1,276 @@ +package zone.moddev.mc.advantageworks; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.Test; +import static org.junit.Assert.*; + +public class WorldgenSamplingJobTest { + @Test public void everyTickHasAFiniteBatchAndAllHaloPopulationPrecedesCounting() { + FakeWorld world = new FakeWorld(); + WorldgenSamplingJob job = job(world, 16); + assertEquals(0, world.biomeQueries); + int ticks = 0; + while (!job.finished()) { + int biomes = world.biomeQueries, populated = world.populated.size(), sampled = world.sampled.size(); + long blocks = world.blocks; + WorldgenSamplingJob.Phase phase = job.phase(); + job.tick(); + assertTrue(world.biomeQueries - biomes <= WorldgenSamplingJob.SEARCH_BATCH); + assertTrue(world.populated.size() - populated <= WorldgenSamplingJob.POPULATION_BATCH); + assertTrue(world.sampled.size() - sampled <= WorldgenSamplingJob.COUNT_BATCH); + assertTrue(world.blocks - blocks <= WorldgenSamplingJob.COUNT_BATCH * 16L * 16L * 97L); + if (phase != WorldgenSamplingJob.Phase.COUNT) assertEquals(0, world.sampled.size()); + assertTrue("Job did not finish", ++ticks < 5000); + } + assertNull(job.failure()); + assertTrue(ticks > 100); + assertEquals(world.sampled.size() * 16L * 16L * 97L, world.blocks); + assertEquals(0, world.minY); + assertEquals(96, world.maxY); + assertEquals(new LinkedHashSet<>(world.populated).size(), world.populated.size()); + assertEquals(new LinkedHashSet<>(world.sampled).size(), world.sampled.size()); + } + + @Test public void resultsAndVisitOrderEqualTheOldAlgorithmAtNegativeCoordinates() { + assertOldAlgorithmEqual(1, false); + assertOldAlgorithmEqual(2, false); + } + + @Test public void radiusSixteenRetainsTheExactOldCountsCentersHaloAndBounds() { + assertOldAlgorithmEqual(16, false, true); + } + + @Test public void absentBiomeCentersRetainNotFoundAndTheSpawnSquare() { + assertOldAlgorithmEqual(1, true); + } + + @Test public void nativeBlockCenterDistanceTieBreakIsNotIntegerCornerDistance() { + FakeWorld world = new FakeWorld(); + world.nativeDistanceTie = true; + WorldgenSamplingJob job = new WorldgenSamplingJob(world, 0, 70, 0, 64, 1); + finish(job); + // Integer corners tie: (0,-20) visits first, but the native center prefers (-16,-12). + assertEquals("-16,-12", ((Map) job.result().get("centers")).get("DESERT")); + } + + @Test public void duplicateRequestsDoNotReplaceAnActiveJob() { + WorldgenSamplingJob.Slot slot = new WorldgenSamplingJob.Slot(); + WorldgenSamplingJob first = job(new FakeWorld(), 1); + slot.start(first); + try { slot.start(job(new FakeWorld(), 1)); fail("Duplicate job accepted"); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("already running")); } + assertSame(first, slot.current()); + slot.release(); + WorldgenSamplingJob next = job(new FakeWorld(), 1); + slot.start(next); + assertSame(next, slot.current()); + } + + @Test public void populationFailureIsTerminalAndNeverProducesAPartialReport() { + FakeWorld world = new FakeWorld(); + world.failPopulation = true; + WorldgenSamplingJob job = job(world, 1); + finish(job); + assertEquals(WorldgenSamplingJob.Phase.FAILED, job.phase()); + assertNotNull(job.failure()); + assertEquals(0, world.sampled.size()); + int populated = world.populated.size(); + job.tick(); + assertEquals(populated, world.populated.size()); + requireIncomplete(job); + } + + @Test public void countFailureIsTerminalAndNeverProducesAPartialReport() { + FakeWorld world = new FakeWorld(); + world.failCount = true; + WorldgenSamplingJob job = job(world, 1); + finish(job); + assertEquals(WorldgenSamplingJob.Phase.FAILED, job.phase()); + assertNotNull(job.failure()); + long blocks = world.blocks; + job.tick(); + assertEquals(blocks, world.blocks); + requireIncomplete(job); + } + + @Test public void anUnfinishedJobHasNoCompletedResult() { requireIncomplete(job(new FakeWorld(), 1)); } + + @Test public void completionIsIdempotent() { + FakeWorld world = new FakeWorld(); + WorldgenSamplingJob job = job(world, 1); + finish(job); + Map result = job.result(); + long blocks = world.blocks; + job.tick(); + assertEquals(result, job.result()); + assertEquals(blocks, world.blocks); + } + + @Test(expected = IllegalArgumentException.class) public void rejectsZeroRadius() { job(new FakeWorld(), 0); } + @Test(expected = IllegalArgumentException.class) public void rejectsRadiusAboveSixteen() { job(new FakeWorld(), 17); } + + private static WorldgenSamplingJob job(FakeWorld world, int radius) { + return new WorldgenSamplingJob(world, -1, 70, -17, 64, radius); + } + + private static void finish(WorldgenSamplingJob job) { + int ticks = 0; + while (!job.finished()) { + job.tick(); + assertTrue(++ticks < 5000); + } + } + + private static void requireIncomplete(WorldgenSamplingJob job) { + try { job.result(); fail("Partial report accepted"); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("not complete")); } + } + + private static void assertOldAlgorithmEqual(int radius, boolean absent) { + assertOldAlgorithmEqual(radius, absent, false); + } + + private static void assertOldAlgorithmEqual(int radius, boolean absent, boolean disjoint) { + FakeWorld oldWorld = new FakeWorld(), batchedWorld = new FakeWorld(); + oldWorld.absent = batchedWorld.absent = absent; + oldWorld.disjoint = batchedWorld.disjoint = disjoint; + Map old = synchronousReference(oldWorld, radius); + WorldgenSamplingJob job = job(batchedWorld, radius); + finish(job); + assertNull(job.failure()); + assertEquals(old, job.result()); + assertEquals(oldWorld.populated, batchedWorld.populated); + assertEquals(oldWorld.sampled, batchedWorld.sampled); + assertEquals(oldWorld.biomeQueries, batchedWorld.biomeQueries); + assertEquals(oldWorld.blocks, batchedWorld.blocks); + if (disjoint) { + assertEquals(3267, job.result().get("chunksScanned")); + assertEquals(4107, batchedWorld.populated.size()); + assertEquals(3267L * 16L * 16L * 97L, batchedWorld.blocks); + } + } + + // Independent transcription of C's synchronous loops, including native candidate-center distance. + private static Map synchronousReference(FakeWorld world, int radius) { + int spawnX = -1, spawnY = 70, spawnZ = -17; + Set chunks = new LinkedHashSet<>(); + Map centers = new LinkedHashMap<>(); + square(chunks, spawnX >> 4, spawnZ >> 4, radius); + centers.put("spawn", (spawnX >> 4) + "," + (spawnZ >> 4)); + for (String biome : Arrays.asList("DESERT", "OCEAN")) { + int[] located = null; + double best = Double.POSITIVE_INFINITY; + for (int offsetZ = -256; offsetZ <= 256; offsetZ += 4) { + for (int offsetX = -256; offsetX <= 256; offsetX += 4) { + int x = ((spawnX >> 4) + offsetX) << 4, z = ((spawnZ >> 4) + offsetZ) << 4; + if (!world.matchesBiome(biome, x, 64, z)) continue; + double dx = x + 0.5D - spawnX, dy = 64.5D - spawnY, dz = z + 0.5D - spawnZ; + double distance = dx * dx + dy * dy + dz * dz; + if (distance < best) { located = new int[] {x >> 4, z >> 4}; best = distance; } + } + } + centers.put(biome, located == null ? "NOT_FOUND" : located[0] + "," + located[1]); + if (located != null) square(chunks, located[0], located[1], radius); + } + Set halo = new LinkedHashSet<>(); + for (long packed : chunks) square(halo, (int) (packed >> 32), (int) packed, 2); + for (long packed : halo) world.populateChunk((int) (packed >> 32), (int) packed); + Map expected = new LinkedHashMap<>(); + expected.put("poweradvantage:crude_oil", "DESERT"); + expected.put("mineralogy:crude_oil", "OCEAN"); + expected.put("electricadvantage:li_ore", null); + expected.put("electricadvantage:sulfur_ore", null); + expected.put("mineralogy:sulfur_ore", null); + Map counts = new LinkedHashMap<>(), violations = new LinkedHashMap<>(), boundaries = new LinkedHashMap<>(); + for (String id : expected.keySet()) { + counts.put(id, 0L); + if (expected.get(id) != null) { violations.put(id, 0L); boundaries.put(id, 0L); } + } + for (long packed : chunks) { + int chunkX = (int) (packed >> 32), chunkZ = (int) packed; + WorldgenSamplingJob.SampleChunk chunk = world.sampleChunk(chunkX, chunkZ); + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + for (int y = 0; y <= 96; y++) { + int x = (chunkX << 4) + localX, z = (chunkZ << 4) + localZ; + String name = chunk.blockName(x, y, z); + if (name == null || !counts.containsKey(name)) continue; + counts.put(name, counts.get(name) + 1L); + String biome = expected.get(name); + if (biome != null && !chunk.centerMatches(biome)) violations.put(name, violations.get(name) + 1L); + else if (biome != null && !world.matchesBiome(biome, x, y, z)) boundaries.put(name, boundaries.get(name) + 1L); + } + } + } + } + Map payload = new LinkedHashMap<>(); + payload.put("radius", radius); + payload.put("chunksScanned", chunks.size()); + payload.put("centers", centers); + payload.put("counts", counts); + payload.put("biomeViolations", violations); + payload.put("biomeBoundaryBlocks", boundaries); + return payload; + } + + private static void square(Set chunks, int centerX, int centerZ, int radius) { + for (int z = centerZ - radius; z <= centerZ + radius; z++) { + for (int x = centerX - radius; x <= centerX + radius; x++) chunks.add(pack(x, z)); + } + } + + private static long pack(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); } + + private static final class FakeWorld implements WorldgenSamplingJob.WorldAccess { + final List populated = new ArrayList<>(), sampled = new ArrayList<>(); + int biomeQueries, minY = Integer.MAX_VALUE, maxY = Integer.MIN_VALUE; + long blocks; + boolean absent, failPopulation, failCount, nativeDistanceTie, disjoint; + + @Override public boolean matchesBiome(String expected, int x, int y, int z) { + biomeQueries++; + if (absent) return false; + if (nativeDistanceTie) return "DESERT".equals(expected) + && ((x == 0 && z == -320) || (x == -256 && z == -192)); + if (disjoint) return z == -32 && x == ("DESERT".equals(expected) ? -1296 : 1264); + boolean region = "DESERT".equals(expected) ? x < 0 : x >= 64; + return region && (y == 64 || ((x + z) & 1) != 0); + } + + @Override public void populateChunk(int x, int z) { + if (failPopulation) throw new IllegalStateException("population failed"); + populated.add(pack(x, z)); + } + + @Override public WorldgenSamplingJob.SampleChunk sampleChunk(int chunkX, int chunkZ) { + sampled.add(pack(chunkX, chunkZ)); + return new WorldgenSamplingJob.SampleChunk() { + @Override public boolean centerMatches(String expected) { + return "DESERT".equals(expected) ? chunkX < 0 : chunkX >= 4; + } + @Override public String blockName(int x, int y, int z) { + if (failCount) throw new IllegalStateException("count failed"); + if ((x >> 4) != chunkX || (z >> 4) != chunkZ || y < 0 || y > 96) { + throw new AssertionError("Scan escaped the original chunk/y bounds"); + } + blocks++; + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + if ((z & 15) != 0) return null; + if ((x & 15) == 0 && y == 0) return "poweradvantage:crude_oil"; + if ((x & 15) == 1 && y == 96) return "mineralogy:crude_oil"; + if ((x & 15) == 2 && y == 48) return "electricadvantage:li_ore"; + if ((x & 15) == 3 && y == 49) return "electricadvantage:sulfur_ore"; + if ((x & 15) == 4 && y == 50) return "mineralogy:sulfur_ore"; + return "minecraft:stone"; + } + }; + } + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native114ProfileTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native114ProfileTest.java index 1bd3548..3065d86 100644 --- a/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native114ProfileTest.java +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native114ProfileTest.java @@ -32,11 +32,11 @@ public void profilesSeparateUnqualifiedDestinationsFromHistoricalSources() throw assertEquals(new File(".").getCanonicalFile(), WorksRunner.findTestRig(file)); if (raw.get("portStatus").getAsString().equals("UNPORTED")) { blocked++; - try { WorksRunner.readProfile(file); fail("Unported profile was accepted: " + file); } + try { WorksRunner.readProfile(file, "1.14.4", "1.14.4-28.2.26"); fail("Unported profile was accepted: " + file); } catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("not ported")); } } else { ported++; - WorksRunner.readProfile(file); + WorksRunner.readProfile(file, "1.14.4", "1.14.4-28.2.26"); int harness = 0; for (JsonElement element : raw.getAsJsonArray("mods")) { JsonObject mod = element.getAsJsonObject(); @@ -59,7 +59,7 @@ public void explicitlyUnportedProfilesStillFailClosed() throws Exception { Files.write(file.toPath(), ("{\"name\":\"blocked\",\"mode\":\"packaged\"," + "\"minecraftVersion\":\"1.14.4\",\"portStatus\":\"UNPORTED\"," + "\"portBlocker\":\"missing genuine native dependency\"}").getBytes(StandardCharsets.UTF_8)); - try { WorksRunner.readProfile(file); fail("Unported profile was accepted"); } + try { WorksRunner.readProfile(file, "1.14.4", "1.14.4-28.2.26"); fail("Unported profile was accepted"); } catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("not ported")); } } finally { Files.delete(file.toPath()); diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native115ProfileTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native115ProfileTest.java new file mode 100644 index 0000000..9a30333 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/Native115ProfileTest.java @@ -0,0 +1,113 @@ +package zone.moddev.mc.advantageworks.runner; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.File; +import java.io.FileReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.Test; +import static org.junit.Assert.*; + +public class Native115ProfileTest { + @Test + public void profilesSeparateUnqualifiedDestinationsFromHistoricalSources() throws Exception { + File[] files = new File("profiles/native115").listFiles((dir, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(10, files.length); + int ported = 0, blocked = 0; + for (File file : files) { + JsonObject raw; + try (FileReader reader = new FileReader(file)) { + raw = new JsonParser().parse(reader).getAsJsonObject(); + } + assertEquals("1.15.2", raw.get("minecraftVersion").getAsString()); + assertEquals("UNQUALIFIED", raw.get("qualificationStatus").getAsString()); + assertEquals("build/works-native115", raw.get("runtimeBase").getAsString()); + assertEquals("1.15.2-31.2.57", raw.getAsJsonObject("forge").get("version").getAsString()); + assertEquals(64, raw.getAsJsonObject("forge").get("sha256").getAsString().length()); + String source = raw.get("legacySourceProfile").getAsString(); + assertTrue(source, source.startsWith("../../../PA113/PowerAdvantage/test-rig/profiles/")); + assertEquals(new File(".").getCanonicalFile(), WorksRunner.findTestRig(file)); + if (raw.get("portStatus").getAsString().equals("UNPORTED")) { + blocked++; + try { WorksRunner.readProfile(file); fail("Unported profile was accepted: " + file); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("not ported")); } + } else { + ported++; + WorksRunner.readProfile(file); + int harness = 0; + for (JsonElement element : raw.getAsJsonArray("mods")) { + JsonObject mod = element.getAsJsonObject(); + assertTrue(file.toString(), mod.get("version").getAsString().endsWith(".115021")); + String path = mod.get("path").getAsString(); + assertFalse(file.toString(), historicalWorkspacePath(path)); + String artifact = new File(path).getName(); + assertEquals(mod.get("file").getAsString(), artifact); + assertTrue(artifact, artifact.contains("-" + mod.get("version").getAsString())); + assertTrue(mod.get("workspaceProduced").getAsBoolean()); + if (mod.get("id").getAsString().equals("advantageworkstest")) harness++; + } + assertEquals(1, harness); + } + } + assertEquals(10, ported); + assertEquals(0, blocked); + } + + @Test public void archiveHashDigitsDoNotIdentifyAHistoricalWorkspace() { + assertFalse(historicalWorkspacePath("build/native115-artifacts/B4CBB113B3/Native-1.0.0.115021.jar")); + for (String line : new String[] {"110", "112", "113", "114"}) { + assertTrue(historicalWorkspacePath("D:/PowerAdvantage/PA" + line + "/PowerAdvantage/build/mod.jar")); + assertTrue(historicalWorkspacePath("D:\\PowerAdvantage\\PA" + line + "\\PowerAdvantage\\build\\mod.jar")); + } + } + + private static boolean historicalWorkspacePath(String path) { + return java.util.regex.Pattern.compile("(^|[/\\\\])PA(?:110|112|113|114)([/\\\\]|$)", + java.util.regex.Pattern.CASE_INSENSITIVE).matcher(path).find(); + } + + @Test + public void explicitlyUnportedProfilesStillFailClosed() throws Exception { + File file = File.createTempFile("unported-native115-", ".json"); + try { + Files.write(file.toPath(), ("{\"name\":\"blocked\",\"mode\":\"packaged\"," + + "\"minecraftVersion\":\"1.15.2\",\"portStatus\":\"UNPORTED\"," + + "\"portBlocker\":\"missing genuine native dependency\"}").getBytes(StandardCharsets.UTF_8)); + try { WorksRunner.readProfile(file); fail("Unported profile was accepted"); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("not ported")); } + } finally { + Files.delete(file.toPath()); + } + } + + @Test + public void pendingNative115PinsFailBeforeAnyRuntimeIsCreated() throws Exception { + WorksRunner.Profile profile = new com.google.gson.Gson().fromJson( + "{\"mods\":[{\"id\":\"advantageworkstest\",\"sha256\":null}]}", WorksRunner.Profile.class); + try { WorksRunner.requireInputPins(profile); fail("Pending candidate pins must fail closed"); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("SHA-256 pin")); } + } + + @Test + public void historical114CannotBeLaunchedAsNative115() throws Exception { + try { + WorksRunner.readProfile(new File("profiles/native114/packaged-current.json")); + fail("Historical 1.14.4 profile must not launch native115"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("Historical source profile")); + } + } + + @Test + public void historicalQualificationCannotBeLaunchedAsNative115() throws Exception { + try { + WorksRunner.readProfile(new File("profiles/packaged-current.json")); + fail("Historical 1.13.2 profile must not launch native115"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("Historical source profile")); + } + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/WorldgenCompletionTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/WorldgenCompletionTest.java new file mode 100644 index 0000000..2999745 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/WorldgenCompletionTest.java @@ -0,0 +1,126 @@ +package zone.moddev.mc.advantageworks.runner; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.TimeUnit; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class WorldgenCompletionTest { + @Rule public TemporaryFolder temporary = new TemporaryFolder(); + + @Test public void archivesStaleSuccessAndFailureBeforeEitherRunWaits() throws Exception { + File results = temporary.newFolder(); + for (String phase : new String[] {"first-run", "restart-run"}) { + File old = new File(results, "worldgen-sample.json"); + File failed = new File(results, "worldgen-sample-failed.json"); + write(old, report()); + write(failed, "{\"failure\":\"previous failure\"}"); + WorksRunner.WorldgenCompletion completion = new WorksRunner.WorldgenCompletion(results, phase); + assertFalse(old.exists()); + assertFalse(failed.exists()); + assertEquals(report(), read(completion.previousReport)); + assertEquals("{\"failure\":\"previous failure\"}", read(completion.previousFailure)); + assertFalse(completion.complete()); + } + } + + @Test public void aNewReportWithIdenticalCountsAndTimestampIsStillFresh() throws Exception { + File results = temporary.newFolder(); + write(new File(results, "worldgen-sample.json"), report()); + WorksRunner.WorldgenCompletion completion = new WorksRunner.WorldgenCompletion(results, "restart-run"); + assertFalse(completion.complete()); + write(completion.report, report()); + assertTrue(completion.complete()); + assertEquals(report(), read(completion.previousReport)); + } + + @Test public void repeatedArchivalNeverReplacesAnOlderReport() throws Exception { + File results = temporary.newFolder(); + write(new File(results, "worldgen-sample.json"), report()); + WorksRunner.WorldgenCompletion first = new WorksRunner.WorldgenCompletion(results, "first-run"); + write(first.report, report()); + WorksRunner.WorldgenCompletion second = new WorksRunner.WorldgenCompletion(results, "first-run"); + assertNotEquals(first.previousReport, second.previousReport); + assertEquals(report(), read(first.previousReport)); + assertEquals(report(), read(second.previousReport)); + } + + @Test public void waitsForPublicationRatherThanAcceptingTheOldReport() throws Exception { + File results = temporary.newFolder(); + write(new File(results, "worldgen-sample.json"), report()); + WorksRunner.WorldgenCompletion completion = new WorksRunner.WorldgenCompletion(results, "first-run"); + int[] pauses = {0}; + WorksRunner.awaitWorldgenCompletion(() -> true, completion, 10L, () -> 0L, () -> { + pauses[0]++; + assertFalse(completion.complete()); + write(completion.report, report()); + }); + assertEquals(1, pauses[0]); + } + + @Test public void staleCompletionCannotSatisfyTheDeadline() throws Exception { + File results = temporary.newFolder(); + write(new File(results, "worldgen-sample.json"), report()); + WorksRunner.WorldgenCompletion completion = new WorksRunner.WorldgenCompletion(results, "restart-run"); + long[] clock = {0L}; + try { + WorksRunner.awaitWorldgenCompletion(() -> true, completion, 3L, () -> clock[0], () -> clock[0]++); + fail("Stale report accepted"); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage().contains("shared shutdown budget")); + } + assertEquals(3L, clock[0]); + assertEquals(report(), read(completion.previousReport)); + } + + @Test public void aFailedJobIsRejectedEvenIfASuccessFileAlsoExists() throws Exception { + WorksRunner.WorldgenCompletion completion = new WorksRunner.WorldgenCompletion(temporary.newFolder(), "first-run"); + write(completion.report, report()); + write(completion.failure, "{\"failure\":\"population failed\"}"); + try { completion.complete(); fail("Failed job accepted"); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("population failed")); } + } + + @Test public void serverExitBeforeCompletionFailsImmediately() throws Exception { + WorksRunner.WorldgenCompletion completion = new WorksRunner.WorldgenCompletion(temporary.newFolder(), "first-run"); + try { + WorksRunner.awaitWorldgenCompletion(() -> false, completion, 10L, () -> 0L, + () -> fail("Exited process must not be polled")); + fail("Exited process accepted"); + } catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("Server exited")); } + } + + @Test public void incompleteReportsAreNotCompletion() throws Exception { + WorksRunner.WorldgenCompletion completion = new WorksRunner.WorldgenCompletion(temporary.newFolder(), "first-run"); + write(completion.report, "{\"format\":1,\"station\":\"worldgen\",\"kind\":\"sample\"}"); + try { completion.complete(); fail("Incomplete report accepted"); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("Incomplete")); } + } + + @Test public void samplingAndShutdownShareTheExistingDeadlineWithoutAnExtension() { + long start = 123L; + assertEquals(start + TimeUnit.SECONDS.toNanos(300), WorksRunner.worldgenDeadline(300, start)); + } + + @Test(expected = IllegalArgumentException.class) public void rejectsAnUnboundedCompletionBudget() { + WorksRunner.worldgenDeadline(0, 0L); + } + + private static String report() { + return "{\"format\":1,\"recordedAtUtc\":\"2026-10-26T13:29:16Z\",\"station\":\"worldgen\"," + + "\"kind\":\"sample\",\"radius\":16,\"chunksScanned\":3267,\"centers\":{},\"counts\":{}," + + "\"biomeViolations\":{},\"biomeBoundaryBlocks\":{}}"; + } + + private static void write(File file, String text) throws Exception { + Files.write(file.toPath(), text.getBytes(StandardCharsets.UTF_8)); + } + + private static String read(File file) throws Exception { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/WorldgenFailureCleanupTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/WorldgenFailureCleanupTest.java new file mode 100644 index 0000000..c45cdab --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/WorldgenFailureCleanupTest.java @@ -0,0 +1,251 @@ +package zone.moddev.mc.advantageworks.runner; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.TimeUnit; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class WorldgenFailureCleanupTest { + @Rule public TemporaryFolder temporary = new TemporaryFolder(); + + @Test public void expiredDeadlineStopsAndDestroysWithoutAnyExtraWait() throws Exception { + FakeProcess process = new FakeProcess(); + Exception failure = requestFailure(process, temporary.newFolder(), System.nanoTime() - 1L); + assertTrue(failure.getMessage().contains("shared shutdown budget")); + assertFalse("Expired scan leaked its owned process", process.alive); + assertTrue(process.commands().contains("stop")); + assertEquals(0, process.waitCalls); + assertEquals(1, process.destroyCalls); + } + + @Test public void staleReportCannotLeaveAnOwnedProcessBehind() throws Exception { + File results = temporary.newFolder(); + File stale = new File(results, "worldgen-sample.json"); + Files.write(stale.toPath(), "old report".getBytes(StandardCharsets.UTF_8)); + FakeProcess process = new FakeProcess(); + requestFailure(process, results, System.nanoTime() - 1L); + assertFalse("Stale completion leaked its owned process", process.alive); + File[] archived = results.listFiles((dir, name) -> name.startsWith("worldgen-sample.json.before-first-run-")); + assertNotNull(archived); + assertEquals(1, archived.length); + assertEquals("old report", new String(Files.readAllBytes(archived[0].toPath()), StandardCharsets.UTF_8)); + assertEquals(0, process.waitCalls); + } + + @Test public void failedReportUsesOnlyTheRemainingBudgetThenDestroys() throws Exception { + File results = temporary.newFolder(); + FakeProcess process = new FakeProcess(); + process.afterCommand = () -> { + try { + if (process.commands().contains("advworks sample-worldgen")) { + Files.write(new File(results, "worldgen-sample-failed.json").toPath(), + "{\"failure\":\"population failed\"}".getBytes(StandardCharsets.UTF_8)); + } + } catch (Exception failure) { throw new IllegalStateException(failure); } + }; + long budget = TimeUnit.SECONDS.toNanos(300); + Exception failure = requestFailure(process, results, System.nanoTime() + budget); + assertTrue(failure.getMessage().contains("population failed")); + assertFalse("Failed report leaked its owned process", process.alive); + assertEquals(1, process.waitCalls); + assertTrue(process.waitNanos > 0 && process.waitNanos <= budget); + assertEquals(1, process.destroyCalls); + } + + @Test public void archiveFailureKeepsThePrimaryExceptionAndStopsBeforeAnySampleCommand() throws Exception { + FakeProcess process = new FakeProcess(); + IOException primary = new IOException("archive failed"); + try { + WorksRunner.withWorldgenFailureCleanup(process, 30L, () -> 27L, () -> { throw primary; }); + fail("Archive failure disappeared"); + } catch (IOException actual) { assertSame(primary, actual); } + assertFalse(process.alive); + assertEquals("stop" + System.lineSeparator(), process.commands()); + assertEquals(3L, process.waitNanos); + assertEquals(0, primary.getSuppressed().length); + } + + @Test public void cleanupUsesTheBudgetRemainingAfterSendingStop() throws Exception { + FakeProcess process = new FakeProcess(); + long[] clock = {27L}; + process.afterCommand = () -> clock[0] = 29L; + Exception primary = new Exception("scan failed"); + try { + WorksRunner.withWorldgenFailureCleanup(process, 30L, () -> clock[0], () -> { throw primary; }); + fail("Scan failure disappeared"); + } catch (Exception actual) { assertSame(primary, actual); } + assertEquals(1L, process.waitNanos); + assertEquals(1, process.destroyCalls); + } + + @Test public void gracefulFailureShutdownDoesNotForceDestroyAnExitedProcess() throws Exception { + FakeProcess process = new FakeProcess(); + process.exitOnWait = true; + Exception primary = new Exception("scan failed"); + try { + WorksRunner.withWorldgenFailureCleanup(process, 30L, () -> 27L, () -> { throw primary; }); + fail("Scan failure disappeared"); + } catch (Exception actual) { assertSame(primary, actual); } + assertFalse(process.alive); + assertEquals(1, process.waitCalls); + assertEquals(0, process.destroyCalls); + } + + @Test public void interruptionDestroysPromptlyWithoutWaitingAndRestoresInterruptStatus() throws Exception { + FakeProcess process = new FakeProcess(); + InterruptedException primary = new InterruptedException("user interrupted"); + try { + try { + WorksRunner.withWorldgenFailureCleanup(process, Long.MAX_VALUE, () -> 0L, () -> { throw primary; }); + fail("Interruption disappeared"); + } catch (InterruptedException actual) { assertSame(primary, actual); } + assertTrue(Thread.currentThread().isInterrupted()); + assertEquals(0, process.waitCalls); + assertFalse(process.alive); + } finally { Thread.interrupted(); } + } + + @Test public void interruptionDuringCleanupIsSuppressedAndStillDestroysPromptly() throws Exception { + FakeProcess process = new FakeProcess(); + Exception primary = new Exception("scan failed"); + process.waitFailure = new InterruptedException("interrupted while stopping"); + try { + try { + WorksRunner.withWorldgenFailureCleanup(process, 30L, () -> 27L, () -> { throw primary; }); + fail("Scan failure disappeared"); + } catch (Exception actual) { assertSame(primary, actual); } + assertTrue(Thread.currentThread().isInterrupted()); + assertArrayEquals(new Throwable[] {process.waitFailure}, primary.getSuppressed()); + assertEquals(1, process.waitCalls); + assertFalse(process.alive); + } finally { Thread.interrupted(); } + } + + @Test public void interruptionWhileSendingStopSkipsTheRemainingWait() throws Exception { + FakeProcess process = new FakeProcess(); + Exception primary = new Exception("scan failed"); + process.afterCommand = () -> Thread.currentThread().interrupt(); + try { + try { + WorksRunner.withWorldgenFailureCleanup(process, Long.MAX_VALUE, () -> 0L, () -> { throw primary; }); + fail("Scan failure disappeared"); + } catch (Exception actual) { assertSame(primary, actual); } + assertTrue(Thread.currentThread().isInterrupted()); + assertEquals(0, process.waitCalls); + assertFalse(process.alive); + } finally { Thread.interrupted(); } + } + + @Test public void stopWriteFailureIsSuppressedAndCannotSkipForcedDestruction() throws Exception { + FakeProcess process = new FakeProcess(); + Exception primary = new Exception("scan failed"); + process.writeFailure = new IOException("stop pipe failed"); + try { + WorksRunner.withWorldgenFailureCleanup(process, 30L, () -> 27L, () -> { throw primary; }); + fail("Scan failure disappeared"); + } catch (Exception actual) { assertSame(primary, actual); } + assertArrayEquals(new Throwable[] {process.writeFailure}, primary.getSuppressed()); + assertEquals(0, process.waitCalls); + assertFalse(process.alive); + } + + @Test public void forcedDestructionFailureIsSuppressedRatherThanReplacingTheScanFailure() throws Exception { + FakeProcess process = new FakeProcess(); + Exception primary = new Exception("scan failed"); + process.destroyFailure = new IllegalStateException("destroy failed"); + try { + WorksRunner.withWorldgenFailureCleanup(process, 30L, () -> 30L, () -> { throw primary; }); + fail("Scan failure disappeared"); + } catch (Exception actual) { assertSame(primary, actual); } + assertArrayEquals(new Throwable[] {process.destroyFailure}, primary.getSuppressed()); + assertEquals(0, process.waitCalls); + assertEquals(1, process.destroyCalls); + } + + @Test public void alreadyExitedProcessNeedsNoCleanupCommandsOrWait() throws Exception { + FakeProcess process = new FakeProcess(); + process.alive = false; + Exception primary = new Exception("process already exited"); + try { + WorksRunner.withWorldgenFailureCleanup(process, 30L, () -> 27L, () -> { throw primary; }); + fail("Scan failure disappeared"); + } catch (Exception actual) { assertSame(primary, actual); } + assertEquals("", process.commands()); + assertEquals(0, process.waitCalls); + assertEquals(0, process.destroyCalls); + } + + @Test public void completedRequestDoesNotStopTheProcessBeforeNormalSaveAndStop() throws Exception { + FakeProcess process = new FakeProcess(); + WorksRunner.withWorldgenFailureCleanup(process, 30L, () -> 27L, () -> {}); + assertTrue(process.alive); + assertEquals("", process.commands()); + assertEquals(0, process.waitCalls); + assertEquals(0, process.destroyCalls); + } + + private static Exception requestFailure(FakeProcess process, File results, long deadline) throws Exception { + Method method = WorksRunner.class.getDeclaredMethod("awaitWorldgen", Process.class, File.class, + String.class, int.class, long.class); + method.setAccessible(true); + try { method.invoke(null, process, results, "first-run", 16, deadline); fail("Request unexpectedly completed"); } + catch (InvocationTargetException failure) { + assertTrue(failure.getCause() instanceof Exception); + return (Exception) failure.getCause(); + } + throw new AssertionError("No failure"); + } + + private static final class FakeProcess extends Process { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + boolean alive = true; + int waitCalls, destroyCalls; + long waitNanos; + boolean exitOnWait; + IOException writeFailure; + InterruptedException waitFailure; + RuntimeException destroyFailure; + Runnable afterCommand = () -> {}; + + String commands() { return new String(output.toByteArray(), StandardCharsets.UTF_8); } + @Override public OutputStream getOutputStream() { + return new OutputStream() { + @Override public void write(int value) throws IOException { + if (writeFailure != null) throw writeFailure; + output.write(value); + } + @Override public void flush() { afterCommand.run(); } + }; + } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(new byte[0]); } + @Override public InputStream getErrorStream() { return new ByteArrayInputStream(new byte[0]); } + @Override public boolean isAlive() { return alive; } + @Override public int waitFor() { throw new AssertionError("Unbounded wait is forbidden"); } + @Override public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException { + waitCalls++; + waitNanos = unit.toNanos(timeout); + if (waitFailure != null) throw waitFailure; + if (exitOnWait) alive = false; + return !alive; + } + @Override public int exitValue() { if (alive) throw new IllegalThreadStateException(); return 0; } + @Override public void destroy() { throw new AssertionError("Use explicit forced cleanup"); } + @Override public Process destroyForcibly() { + destroyCalls++; + if (destroyFailure != null) throw destroyFailure; + alive = false; + return this; + } + } +}