diff --git a/.gitattributes b/.gitattributes
index ca786b7e..d6453928 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,4 +1,8 @@
* text=auto
+
+# Keep the generated Forge defaults unchanged for the startup log controls.
+src/bootstrapTest/defaultconfigs/config/forge-client.toml whitespace=-blank-at-eof
+src/bootstrapTest/defaultconfigs/defaultconfigs/forge-server.toml whitespace=-blank-at-eof
*.java text eol=lf
*.json text eol=lf
*.lang text eol=lf
@@ -10,3 +14,15 @@ gradlew text eol=lf
*.bat text eol=crlf
*.png binary
*.jar binary
+
+# Preserve the exact sealed build fixture and native scripts on Windows.
+.gitattributes text eol=lf
+*.js text eol=lf
+*.yml text eol=lf
+ci-fixtures/tools/** -text
+*.mcmeta text eol=lf
+*.toml text eol=lf
+*.py text eol=lf
+
+# Preserve the bytes of the checksum-locked biome recovery proof.
+src/testFixtures/bop-1.20.1-biome-recovery.json -text whitespace=cr-at-eol
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 68e965ca..a954d9f9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,6 +17,123 @@ concurrency:
cancel-in-progress: true
jobs:
+ cold-forge-bootstrap:
+ name: Cold Forge bootstrap
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Install pinned Java 25 ForgeGradle Mavenizer runtime
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+
+ - name: Install pinned Java 8 compilation toolchain
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '8.0.502+7'
+
+ - name: Install pinned Java 25 compilation toolchain
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+
+ - name: Pin production Java
+ run: echo "SBP_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
+
+ - name: Install pinned Java 25 Gradle runtime
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+
+ - name: Read pinned dependency sources
+ id: content
+ run: |
+ set -euo pipefail
+ pieces="$(sed -n 's/^commit=//p' gradle/core-dependency.properties)"
+ [[ "$pieces" =~ ^[0-9a-f]{40}$ ]]
+ echo "pieces=$pieces" >> "$GITHUB_OUTPUT"
+ - name: Check out SkysBuildingPieces
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ repository: SkyBlade1978/SkysBuildingPieces
+ ref: ${{ steps.content.outputs.pieces }}
+ path: dependencies/pieces
+ - name: Read the core's coordinated Grass Slabs source pin
+ id: grass
+ shell: bash
+ run: |
+ set -euo pipefail
+ commit="$(sed -n 's/^source.commit=//p' dependencies/pieces/gradle/grass-slabs-release.properties)"
+ [[ "$commit" =~ ^[0-9a-f]{40}$ ]]
+ echo "commit=$commit" >> "$GITHUB_OUTPUT"
+
+ - name: Check out pinned Grass Slabs source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ repository: SkyBlade1978/SkysGrassSlabs
+ ref: ${{ steps.grass.outputs.commit }}
+ path: dependencies/grass-slabs
+
+ - name: Build the coordinated Grass Slabs dependency
+ shell: bash
+ run: |
+ set -euo pipefail
+ dependency="$GITHUB_WORKSPACE/dependencies/grass-slabs"
+ chmod +x "$dependency/gradlew"
+ "$dependency/gradlew" -p "$dependency" assemble --no-daemon --stacktrace
+ version="$(sed -n 's/^version=//p' dependencies/pieces/gradle/grass-slabs-release.properties)"
+ echo "ORG_GRADLE_PROJECT_grassSlabsMainJar=$dependency/build/libs/SkysGrassSlabs-$version.jar" >> "$GITHUB_ENV"
+ echo "ORG_GRADLE_PROJECT_grassSlabsDeobfJar=$dependency/build/libs/SkysGrassSlabs-$version.jar" >> "$GITHUB_ENV"
+
+ - name: Build development dependencies
+ run: |
+ set -euo pipefail
+ chmod +x dependencies/*/gradlew
+ (cd dependencies/pieces && ./gradlew assemble --no-daemon --stacktrace)
+
+
+ - name: Bootstrap Forge from an empty cache
+ shell: bash
+ env:
+ GRADLE_USER_HOME: ${{ runner.temp }}/skysbuildingpieces-bop-cold-gradle
+ run: |
+ set -euo pipefail
+ test ! -e .gradle
+ test ! -e "$GRADLE_USER_HOME"
+ mkdir -p "$GRADLE_USER_HOME"
+ chmod +x ./gradlew
+ gradle_args=(
+ classes test -PbuildingPiecesCoreDir=dependencies/pieces
+ --no-daemon --no-build-cache --stacktrace --max-workers=2
+ -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64"
+ -Dorg.gradle.java.installations.auto-detect=false
+ -Dorg.gradle.java.installations.auto-download=false
+ )
+ ./gradlew "${gradle_args[@]}"
+
+ - name: Verify same-cache bootstrap offline
+ shell: bash
+ env:
+ GRADLE_USER_HOME: ${{ runner.temp }}/skysbuildingpieces-bop-cold-gradle
+ run: |
+ set -euo pipefail
+ gradle_args=(
+ classes test -PbuildingPiecesCoreDir=dependencies/pieces
+ --rerun-tasks --offline --no-daemon --no-build-cache --stacktrace --max-workers=2
+ -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64"
+ -Dorg.gradle.java.installations.auto-detect=false
+ -Dorg.gradle.java.installations.auto-download=false
+ )
+ ./gradlew "${gradle_args[@]}"
+
build:
name: Build, test, and audit
runs-on: ubuntu-latest
@@ -37,9 +154,15 @@ jobs:
echo "java_setup_version=${selector:-$toolchain}" >> "$GITHUB_OUTPUT"
echo "java_version=$(value java_version)" >> "$GITHUB_OUTPUT"
echo "minecraft_version=$(value minecraft_version)" >> "$GITHUB_OUTPUT"
- echo "gradle_java_version=$(value gradle_java_version)" >> "$GITHUB_OUTPUT"
+ echo "gradle_java_version=$(value java_setup_version)" >> "$GITHUB_OUTPUT"
echo "artifact_prefix=SkysBuildingPieces-BiomesOPlenty-Addon-$(value mod_version)" >> "$GITHUB_OUTPUT"
+ - name: Install ForgeGradle Mavenizer Java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+
- name: Install target Java toolchain
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
@@ -47,16 +170,12 @@ jobs:
java-version: ${{ steps.target.outputs.java_setup_version }}
- name: Pin production Java
- if: steps.target.outputs.java_version == '8'
- run: |
- echo "SGS_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- echo "SBP_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- echo "STS_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
+ run: echo "SBP_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- name: Install Java for Gradle
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
- distribution: microsoft
+ distribution: temurin
java-version: ${{ steps.target.outputs.gradle_java_version }}
- name: Set up Gradle
@@ -76,11 +195,38 @@ jobs:
repository: SkyBlade1978/SkysBuildingPieces
ref: ${{ steps.content.outputs.pieces }}
path: dependencies/pieces
+ - name: Read the core's coordinated Grass Slabs source pin
+ id: grass
+ shell: bash
+ run: |
+ set -euo pipefail
+ commit="$(sed -n 's/^source.commit=//p' dependencies/pieces/gradle/grass-slabs-release.properties)"
+ [[ "$commit" =~ ^[0-9a-f]{40}$ ]]
+ echo "commit=$commit" >> "$GITHUB_OUTPUT"
+
+ - name: Check out pinned Grass Slabs source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ repository: SkyBlade1978/SkysGrassSlabs
+ ref: ${{ steps.grass.outputs.commit }}
+ path: dependencies/grass-slabs
+
+ - name: Build the coordinated Grass Slabs dependency
+ shell: bash
+ run: |
+ set -euo pipefail
+ dependency="$GITHUB_WORKSPACE/dependencies/grass-slabs"
+ chmod +x "$dependency/gradlew"
+ "$dependency/gradlew" -p "$dependency" assemble --no-daemon --stacktrace
+ version="$(sed -n 's/^version=//p' dependencies/pieces/gradle/grass-slabs-release.properties)"
+ echo "ORG_GRADLE_PROJECT_grassSlabsMainJar=$dependency/build/libs/SkysGrassSlabs-$version.jar" >> "$GITHUB_ENV"
+ echo "ORG_GRADLE_PROJECT_grassSlabsDeobfJar=$dependency/build/libs/SkysGrassSlabs-$version.jar" >> "$GITHUB_ENV"
+
- name: Build development dependencies
run: |
set -euo pipefail
chmod +x dependencies/*/gradlew
- (cd dependencies/pieces && ./gradlew jar --no-daemon --stacktrace)
+ (cd dependencies/pieces && ./gradlew assemble --no-daemon --stacktrace)
- name: Make the wrapper executable
@@ -88,8 +234,9 @@ jobs:
- name: Build, test, and audit release artifacts
run: >-
- ./gradlew clean check integrationTest build javadoc verifyReleaseArtifacts writeReleaseChecksums
+ ./gradlew clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums
verifyEclipseProductionClasspath -PbuildingPiecesCoreDir=dependencies/pieces --no-daemon --stacktrace
+ -x integrationTest -x integrationRecipeBookTest -x nativeInstalledBopPolicyTest
- name: Replay artifact checks offline
run: >-
@@ -118,6 +265,6 @@ jobs:
path: |
build/test-results/**
build/reports/**
- build/*-run/logs/**
- build/*-run/**/*.properties
+ build/*-run*/logs/**
+ build/*-run*/**/*.properties
build/problems/**
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 64c56069..ad7e7dc6 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -37,9 +37,15 @@ jobs:
echo "java_setup_version=${selector:-$toolchain}" >> "$GITHUB_OUTPUT"
echo "java_version=$(value java_version)" >> "$GITHUB_OUTPUT"
echo "minecraft_version=$(value minecraft_version)" >> "$GITHUB_OUTPUT"
- echo "gradle_java_version=$(value gradle_java_version)" >> "$GITHUB_OUTPUT"
+ echo "gradle_java_version=$(value java_setup_version)" >> "$GITHUB_OUTPUT"
echo "artifact_prefix=SkysBuildingPieces-BiomesOPlenty-Addon-$(value mod_version)" >> "$GITHUB_OUTPUT"
+ - name: Install ForgeGradle Mavenizer Java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+
- name: Install target Java toolchain
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
@@ -47,16 +53,12 @@ jobs:
java-version: ${{ steps.target.outputs.java_setup_version }}
- name: Pin production Java
- if: steps.target.outputs.java_version == '8'
- run: |
- echo "SGS_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- echo "SBP_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- echo "STS_JAVA8_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
+ run: echo "SBP_JAVA25_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- name: Install Java for Gradle
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
- distribution: microsoft
+ distribution: temurin
java-version: ${{ steps.target.outputs.gradle_java_version }}
- name: Set up Gradle
@@ -76,11 +78,38 @@ jobs:
repository: SkyBlade1978/SkysBuildingPieces
ref: ${{ steps.content.outputs.pieces }}
path: dependencies/pieces
+ - name: Read the core's coordinated Grass Slabs source pin
+ id: grass
+ shell: bash
+ run: |
+ set -euo pipefail
+ commit="$(sed -n 's/^source.commit=//p' dependencies/pieces/gradle/grass-slabs-release.properties)"
+ [[ "$commit" =~ ^[0-9a-f]{40}$ ]]
+ echo "commit=$commit" >> "$GITHUB_OUTPUT"
+
+ - name: Check out pinned Grass Slabs source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ repository: SkyBlade1978/SkysGrassSlabs
+ ref: ${{ steps.grass.outputs.commit }}
+ path: dependencies/grass-slabs
+
+ - name: Build the coordinated Grass Slabs dependency
+ shell: bash
+ run: |
+ set -euo pipefail
+ dependency="$GITHUB_WORKSPACE/dependencies/grass-slabs"
+ chmod +x "$dependency/gradlew"
+ "$dependency/gradlew" -p "$dependency" assemble --no-daemon --stacktrace
+ version="$(sed -n 's/^version=//p' dependencies/pieces/gradle/grass-slabs-release.properties)"
+ echo "ORG_GRADLE_PROJECT_grassSlabsMainJar=$dependency/build/libs/SkysGrassSlabs-$version.jar" >> "$GITHUB_ENV"
+ echo "ORG_GRADLE_PROJECT_grassSlabsDeobfJar=$dependency/build/libs/SkysGrassSlabs-$version.jar" >> "$GITHUB_ENV"
+
- name: Build development dependencies
run: |
set -euo pipefail
chmod +x dependencies/*/gradlew
- (cd dependencies/pieces && ./gradlew jar --no-daemon --stacktrace)
+ (cd dependencies/pieces && ./gradlew assemble --no-daemon --stacktrace)
- name: Initialize CodeQL
diff --git a/.gitignore b/.gitignore
index 930754a7..8b4651d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,3 +77,8 @@ agent_notes/
# Files from Forge MDK
forge*changelog.txt
/README.txt
+
+# Python resource and validation helpers
+__pycache__/
+*.py[cod]
+.pytest_cache/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42648017..74d6359b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,88 @@
# Changelog
+## 0.2.5.2603001
+
+- BOP building pieces and recovered rocks now have individual block IDs. Existing builds and rock variants carry forward.
+
+## 0.2.4.2603001
+
+- Updated the add-on for Minecraft 26.3 on Forge.
+- Saved maple leaves, saplings and litter that Biomes O' Plenty removed now
+ use the closest vanilla equivalents. Planted flowerpots stay planted.
+ Potted sapling items become poplar saplings.
+- Signs keep both their visible text and filtered text through an upgrade.
+
+## 0.2.4.2602001
+
+- Updated the add-on for Minecraft 26.2 on Forge.
+
+## 0.2.4.2601021
+
+- Updated to Minecraft 26.1.2 and Forge 64.0.9.
+- Kept the Flower Basket and its two-row inventory on the updated Minecraft interface.
+
+## 0.2.4.121011
+
+- Updated the add-on to Minecraft 1.21.1 and Forge 52.1.0.
+- Updated crafting and recipe-book data for this version.
+
+## 0.2.4.120061
+
+- Ported to Minecraft 1.20.6 and Forge 50.2.0.
+- Kept existing building pieces and Flower Basket contents when upgrading.
+- Updated the crafting recipes and recipe book for this version.
+
+## 0.2.4.120011
+
+- Ported to Minecraft 1.20.1 and Forge 47.4.10.
+- Kept existing pieces and baskets. Added replacements for removed BOP wood,
+ plants and biomes when opening older worlds.
+
+## 0.2.4.119041
+
+- Added the Minecraft 1.19.4 version for Forge 45.4.0.
+- Kept the building catalogue, Flower Basket and saved pieces compatible with earlier versions.
+- Added replacements for BOP's removed mud pieces, rooted sand and four biomes.
+- Updated crafting-book categories and the vanilla mud-brick textures.
+
+## 0.2.4.118021 - Minecraft 1.18.2 (unreleased)
+
+- Bring the BOP pieces and Flower Basket to Minecraft 1.18.2.
+- Use BOP with TerraBlender and the matching core.
+- Replace removed roots with similar plants, keeping water and growth states.
+- Retain saved biome identities through the new chunk format.
+
+## 0.2.4.117011 - Minecraft 1.17.1 (unreleased)
+
+- Bring the existing BOP pieces, decorations and Flower Basket to Minecraft 1.17.1.
+- Keep saved material palettes, orientations, waterlogging and basket contents.
+- Replace removed nether crystals with rose quartz, keeping their attachment directions.
+- Keep recipe-book unlocks and translated material names working with the matching core.
+
+## 0.2.4.116051
+
+- Added the Minecraft 1.16.5 port for Forge 36.2.34 and BOP 13.1.0.488.
+- Kept existing piece IDs, material slots, Flower Baskets and saved contents.
+- Added replacements for BOP plants and biomes removed on this upgrade route.
+
+
+## 0.2.4.115021 - Minecraft 1.15.2 (unreleased)
+
+- Bring BOP building pieces, restored decorations and the Flower Basket to 1.15.2.
+- Use the matching core pieces and BOP 10 materials.
+- Recover retired ethereal wood as birch, with white cherry leaves and saplings.
+- Replace dried sand with dried salt and barley with tall wheat.
+- Preserve saved biome identities when upgrading, including chunks opened later.
+
+## 0.2.4.114041
+
+- Ported to Minecraft 1.14.4 and native waterlogging.
+- Reused BOP's current slabs, stairs and walls where equivalent pieces exist.
+- Added replacements for retired woods, gems and thatching when loading old pieces.
+- Kept Flower Basket inventories, with old plant names converted before loading.
+- Preserved existing migration totals and supported saved, unpopulated chunks.
+- Added recipe-book unlocks for converting fallback rocks to Mineralogy blocks.
+
## 0.2.4.112021
- Updated for Building Pieces 0.3.8.112021.
diff --git a/NOTICE b/NOTICE
index bc184eaf..e95c6069 100644
--- a/NOTICE
+++ b/NOTICE
@@ -19,3 +19,6 @@ see MINERALOGY-LGPL-3.0.txt and MINERALOGY-GPL-3.0.txt. The original PNG source
files are included in the source distribution and can be replaced by a
resource pack. No Mineralogy Java code is bundled.
Source: https://github.com/MinecraftModDevelopmentMods/MinecraftMineralogy
+
+The build checks also include a small snapshot of Mineralogy's native recipe
+and tag data, under the same accompanying Mineralogy licence notices.
diff --git a/README.md b/README.md
index 758c37ed..1036f936 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,22 @@
-# Sky's Building Pieces — Biomes O' Plenty
+# Sky's Building Pieces — Biomes O' Plenty
-
+
Bring Biomes O' Plenty materials into your builds with the same range of pieces
as Sky's Building Pieces. This add-on supplies missing slabs, steps, corners,
-stairs and stone or gem walls, using the textures of the original BOP blocks.
+stairs and stone walls, using the textures of the original BOP blocks.
## Building with BOP materials
-Build with all 16 plank woods, bamboo thatching, eight stone materials
-and eight gem blocks. Redwood, ebony and eucalyptus are included. Where BOP
-already has a suitable slab or stair, the add-on uses it rather than adding
-another copy.
+Build with the woods and stone materials in Biomes O' Plenty for Minecraft
+26.3. Where BOP already has a suitable slab, stair or wall, the add-on uses
+it rather than adding another copy.
Craft pieces from matching blocks, slabs and steps, following the core mod's
patterns. Regular slabs also place vertically and drop regular slabs when
broken. Matching pieces can join into supported larger shapes.
-Stone and gem walls have lower panels and raised posts, just like the core's
+Stone walls have lower panels and raised posts, just like the core's
walls. Use BOP's own fences and gates for wooden builds; this add-on doesn't add
wooden walls or generate terrain.
@@ -32,17 +31,18 @@ anything that doesn't fit handled normally.
Craft a basket from six wooden sticks. The [basket guide](docs/FLOWER-BASKET.md)
shows the recipe and explains storage and automatic collection. The basket
-holds plant blocks, not ordinary seeds or unrelated items.
+holds suitable plant block items, including crop seeds that place plant blocks.
+It doesn't accept unrelated items.
## Installation
-Use the versions for **Minecraft 1.12.2**, with **Forge 14.23.5.2859** and
-**Java 8**. You'll need:
+Use the versions for **Minecraft 26.3**, with **Forge 66.0.2** and
+**Java 25**. You'll need:
-- **Sky's Building Pieces 0.3.8.112021** and this add-on.
-- **Biomes O' Plenty 7.0.1.2445**.
+- **Sky's Building Pieces 0.3.9.2603001** and this add-on.
+- **Biomes O' Plenty 26.3.0.0.16**, **TerraBlender 26.3.0.0.2** and **GlitchCore 26.3.0.0.0**.
-Put all three main jars in `mods` on both the client and server.
+Put all five main jars in `mods` on both the client and server.
Downloads are on [CurseForge](https://www.curseforge.com/minecraft/mc-mods/skysbuildingpiecesbop).
For smoother BOP grass and soil, use the separate
@@ -51,10 +51,15 @@ with Sky's Terrain Smoother.
## Existing worlds
-Before moving a world from 1.10 to 1.12, read
+Before moving an existing world to this version, read
[existing worlds and modpack notes](docs/WORLD-UPGRADES.md). It covers recovery
for supported old building pieces, retired BOP rocks, decorations, Flower
-Baskets and other items, including any replacement mods you'll need.
+Baskets and other items. BOP has removed some woods, gems and thatching since
+1.12; their saved pieces use similar current woods or coloured terracotta.
+Ethereal wood removed after 1.14 uses birch, with white cherry leaves and saplings.
+Other retired plants and biomes use the replacements listed in the upgrade guide.
+Nether crystals removed in BOP 1.17 become rose quartz, keeping their attachment
+directions and stored item data.
Mineralogy is optional. Retired rocks use its matching blocks when available;
otherwise the add-on supplies its own full-block replacements using Mineralogy
diff --git a/build.gradle b/build.gradle
index 465bd83d..c305c082 100644
--- a/build.gradle
+++ b/build.gradle
@@ -4,16 +4,78 @@ import java.nio.file.Files
import java.security.MessageDigest
import java.util.zip.ZipFile
import org.apache.tools.ant.filters.FixCrLfFilter
-
plugins {
id 'java'
id 'eclipse'
id 'idea'
id 'maven-publish'
- id 'net.minecraftforge.renamer' version '1.1.5'
id 'net.minecraftforge.gradle' version '7.0.34'
}
+def sha256Of = { File inputFile ->
+ MessageDigest digest = MessageDigest.getInstance('SHA-256')
+ inputFile.withInputStream { input ->
+ byte[] buffer = new byte[8192]
+ for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) {
+ if (read > 0) digest.update(buffer, 0, read)
+ }
+ }
+ digest.digest().encodeHex().toString().toUpperCase()
+}
+
+def mavenizerToolsDirectory = layout.projectDirectory.dir('ci-fixtures/tools')
+def mavenizerCompatibilityJar = mavenizerToolsDirectory.file(
+ 'minecraft-mavenizer-0.5.21-orespawn-compat.jar')
+def mavenizerRuleManifest = mavenizerToolsDirectory.file(
+ 'minecraft-source-compatibility.json')
+def mavenizerSourcePatch = mavenizerToolsDirectory.file(
+ 'minecraft-mavenizer-0.5.21-orespawn-compat.patch')
+def mavenizerLicense = mavenizerToolsDirectory.file('LICENSE-MAVENIZER.txt')
+def mavenizerReadme = mavenizerToolsDirectory.file('README.md')
+def mavenizerFixtureChecksums = [
+ (mavenizerCompatibilityJar.asFile):
+ '9DA1F44821D89B16B3E181CA89057F44CD3FC3F3383EA5A16442872E67E30F35',
+ (mavenizerRuleManifest.asFile):
+ 'C765A5F65FB9CD176F3CE08CB0CFCA814D06E649940E2BF169CD8943AFC30B26',
+ (mavenizerSourcePatch.asFile):
+ 'BBD82C6BD96206DEA2FE2673F4D7E76FBA92884032455BBFD5CCD59045C5E271',
+ (mavenizerLicense.asFile):
+ '20C17D8B8C48A600800DFD14F95D5CB9FF47066A9641DDEAB48DC54AEC96E331',
+ (mavenizerReadme.asFile):
+ 'A611E2BF14E29761B99BF7D3F0FD44AF151DD09A69968C0CFAEC384BD0E7A666'
+]
+
+// ForgeGradle invokes Mavenizer while configuring the project. Verify the
+// complete five-file fixture before any Mavenizer code is allowed to run.
+mavenizerFixtureChecksums.keySet().each { fixture ->
+ if (!fixture.isFile()) {
+ throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}")
+ }
+ String actual = sha256Of(fixture)
+ String expected = mavenizerFixtureChecksums[fixture]
+ if (actual != expected) {
+ throw new GradleException("Mavenizer compatibility fixture checksum mismatch for " +
+ "${fixture.name}: expected ${expected}, found ${actual}")
+ }
+}
+
+fgtools.configure('mavenizer') {
+ classpath.from(mavenizerCompatibilityJar)
+ mainClass.set('net.minecraftforge.mcmaven.cli.Main')
+ javaLauncher.set(javaToolchains.launcherFor {
+ languageVersion = JavaLanguageVersion.of(25)
+ vendor = JvmVendorSpec.ADOPTIUM
+ })
+}
+
+// The Forge launcher runs on the same Java version as Minecraft.
+fgtools.configure('slimelauncher') {
+ javaLauncher.set(javaToolchains.launcherFor {
+ languageVersion = JavaLanguageVersion.of(25)
+ vendor = JvmVendorSpec.ADOPTIUM
+ })
+}
+
group = project.mod_group
version = project.mod_version
base.archivesName = 'SkysBuildingPieces-BiomesOPlenty-Addon'
@@ -22,10 +84,14 @@ def versionParts = project.mod_version.toString().tokenize('.')
if (versionParts.size() != 4 || !versionParts.every { it ==~ /\d+/ }) {
throw new GradleException("mod_version must use Major.Minor.Bug.Target numeric form: ${project.mod_version}")
}
-def minecraftVersionParts = project.minecraft_version.toString().tokenize('.')
+def minecraftVersionParts = project.minecraft_version.toString().split('\\.', -1)
+if (!(minecraftVersionParts.length in 2..3) || !minecraftVersionParts.every { it ==~ /\d+/ }) {
+ throw new GradleException("minecraft_version must have two or three numeric parts: ${project.minecraft_version}")
+}
+def minecraftPatch = minecraftVersionParts.length == 3 ? minecraftVersionParts[2] : '0'
def expectedTargetVersion = "${minecraftVersionParts[0]}" +
"${minecraftVersionParts[1].padLeft(2, '0')}" +
- "${minecraftVersionParts[2].padLeft(2, '0')}" + project.loader_code
+ "${minecraftPatch.padLeft(2, '0')}" + project.loader_code
if (versionParts[3] != expectedTargetVersion) {
throw new GradleException("mod_version target ${versionParts[3]} does not match ${expectedTargetVersion}")
}
@@ -33,29 +99,29 @@ if (versionParts[3] != expectedTargetVersion) {
ext.functional_version = versionParts[0..2].join('.')
ext.display_version = project.mod_version
ext.release_tag = project.mod_version
-def pinnedJava8 = providers.environmentVariable('SBP_JAVA8_HOME')
-// Gradle 9.6.1's ordinary language-level selector prefers the newest cached
-// Java 8. This opt-in specific installation keeps local/CI qualification exact.
-def exactJava8Spec = pinnedJava8.isPresent() ?
+def pinnedJava25 = providers.environmentVariable('SBP_JAVA25_HOME')
+// Gradle 9.7.1's ordinary language-level selector prefers the newest cached
+// Java 25. This opt-in specific installation keeps local/CI qualification exact.
+def exactJava25Spec = pinnedJava25.isPresent() ?
org.gradle.jvm.toolchain.internal.SpecificInstallationToolchainSpec.fromJavaHome(
- project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava8.get())) : null
+ project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava25.get())) : null
java {
- toolchain.languageVersion = JavaLanguageVersion.of(8)
+ toolchain.languageVersion = JavaLanguageVersion.of(25)
withSourcesJar()
withJavadocJar()
}
tasks.withType(JavaCompile).configureEach {
javaCompiler = javaToolchains.compilerFor {
- languageVersion = JavaLanguageVersion.of(8)
+ languageVersion = JavaLanguageVersion.of(25)
}
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_25
+ targetCompatibility = JavaVersion.VERSION_25
options.encoding = 'UTF-8'
options.compilerArgs.addAll(['-Xmaxerrs', '1000'])
- if (pinnedJava8.isPresent()) {
- javaCompiler = javaToolchains.compilerFor(exactJava8Spec)
+ if (pinnedJava25.isPresent()) {
+ javaCompiler = javaToolchains.compilerFor(exactJava25Spec)
}
}
@@ -69,28 +135,35 @@ tasks.withType(Test).configureEach {
// Repository checks read these files as well as the compiled test classes.
inputs.files('build.gradle', 'gradle.properties', '.gitignore', 'README.md')
inputs.files(fileTree('gradle'), fileTree('.github/workflows'), fileTree('docs'))
- if (pinnedJava8.isPresent()) javaLauncher = javaToolchains.launcherFor(exactJava8Spec)
+ inputs.files(fileTree('src/testFixtures'))
+ inputs.files('tools/native-log-profiles.json', 'tools/native-log-forge52-profiles.json')
+ inputs.files(fileTree('tools/fixtures'))
+ if (pinnedJava25.isPresent()) javaLauncher = javaToolchains.launcherFor(exactJava25Spec)
}
tasks.withType(JavaExec).configureEach {
- if (pinnedJava8.isPresent()) javaLauncher = javaToolchains.launcherFor(exactJava8Spec)
+ if (pinnedJava25.isPresent()) javaLauncher = javaToolchains.launcherFor(exactJava25Spec)
}
tasks.withType(Javadoc).configureEach {
- if (pinnedJava8.isPresent()) javadocTool = javaToolchains.javadocToolFor(exactJava8Spec)
- failOnError = false
+ if (pinnedJava25.isPresent()) javadocTool = javaToolchains.javadocToolFor(exactJava25Spec)
+ failOnError = true
options.encoding = 'UTF-8'
options.addStringOption('Xdoclint:none', '-quiet')
options.addBooleanOption('notimestamp', true)
}
tasks.withType(AbstractArchiveTask).configureEach {
+ // Generated empty folders do not exist in a fresh checkout.
+ includeEmptyDirs = false
+ filteringCharset = 'UTF-8'
+ inputs.property('archiveLineEndings', 'utf8-lf-with-toml')
preserveFileTimestamps = false
reproducibleFileOrder = true
}
def archiveTextSuffixes = [
'.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.mcmeta', '.md',
- '.properties', '.txt', '.xml'
+ '.properties', '.toml', '.txt', '.xml'
]
def archiveTextPatterns = archiveTextSuffixes.collect { "**/*${it}".toString() }
archiveTextPatterns.addAll([
@@ -103,51 +176,75 @@ def normalizeArchiveLineEndings = { details ->
eof: FixCrLfFilter.AddAsisRemove.newInstance('asis'))
}
-def developmentRunDirectory = providers.gradleProperty('skysBuildingPiecesRunDirectory').getOrElse('run')
+def developmentRunDirectory = providers.gradleProperty('skysBuildingPiecesBopRunDirectory').getOrElse('run')
minecraft {
mappings channel: project.mapping_channel, version: project.mapping_version
runs {
configureEach {
+ jvmArgs '--add-opens', 'java.base/java.lang.invoke=ALL-UNNAMED'
workingDir.convention layout.projectDirectory.dir(developmentRunDirectory)
- // Supply the legacy version before ForgeGradle expands the launch environment.
- environment 'MC_VERSION', project.minecraft_version.toString()
+ systemProperty 'eventbus.api.strictRuntimeChecks',
+ providers.gradleProperty('bopStrictEventBus').getOrElse('false')
+ systemProperty 'forge.logging.markers', 'REGISTRIES'
systemProperty 'forge.logging.console.level', 'debug'
- systemProperty 'fml.coreMods.load', 'zone.moddev.mc.skysbuildingpieces.legacy.LoaderPlugin'
+ mods { skysbuildingpiecesbop { source sourceSets.main } }
+ environment 'MOD_CLASSES',
+ "${mod_id}%%${buildDir}/forge-run-mod-classes/main"
+ if (name.toLowerCase(Locale.ROOT).contains('client') &&
+ providers.gradleProperty('forgeClientAssets').isPresent()) {
+ environment 'assetIndex', '34'
+ environment 'assetDirectory', providers.gradleProperty('forgeClientAssets').get()
+ if (providers.gradleProperty('forgeClientNatives').isPresent()) {
+ environment 'nativesDirectory', providers.gradleProperty('forgeClientNatives').get()
+ }
+ }
}
register('client')
register('server') { args '--nogui' }
}
}
-// Forge 1.12 discovers a development mod from one classes/resources root.
-def forge12DevelopmentOutput = layout.buildDirectory.dir('forge12-development/main')
-def syncForge10DevelopmentResources = tasks.register('syncForge10DevelopmentResources', Sync) {
- group = 'ide'
- dependsOn tasks.named('classes')
- from tasks.named('compileJava', JavaCompile).flatMap { it.destinationDirectory }
+def mixinConfig = 'skysbuildingpiecesbop.mixins.json'
+def developmentManifest = layout.buildDirectory.file('forge-run-metadata/META-INF/MANIFEST.MF')
+def prepareDevelopmentManifest = tasks.register('prepareDevelopmentManifest') {
+ inputs.property('mixinConfig', mixinConfig)
+ outputs.file(developmentManifest)
+ doLast {
+ File manifestFile = developmentManifest.get().asFile
+ manifestFile.parentFile.mkdirs()
+ manifestFile.setText("Manifest-Version: 1.0\r\nMixinConfigs: ${mixinConfig}\r\n\r\n", 'UTF-8')
+ }
+}
+
+def forgeRunModClassesDirectory = file("${buildDir}/forge-run-mod-classes/main")
+def prepareForgeRunModClasses = tasks.register('prepareForgeRunModClasses', Sync) {
+ dependsOn tasks.named('classes'), prepareDevelopmentManifest
+ from sourceSets.main.output.classesDirs
from sourceSets.main.output.resourcesDir
- into forge12DevelopmentOutput
-}
-tasks.configureEach {
- if (name in ['runClient', 'runServer', 'runTestClient', 'runTestServer']) {
- dependsOn syncForge10DevelopmentResources
- doFirst {
- if (pinnedJava8.isPresent()) executable = javaToolchains.launcherFor(exactJava8Spec).get().executablePath.asFile.path
- Set splitMainOutputs = [
- tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile,
- sourceSets.main.output.resourcesDir
- ] as Set
- classpath = files(forge12DevelopmentOutput, classpath.filter {
- !splitMainOutputs.contains(it)
- })
+ from(developmentManifest) { into 'META-INF' }
+ into forgeRunModClassesDirectory
+}
+
+def isolatedForgeClasspath = { FileCollection classpath, boolean includeMain ->
+ Set splitOutputs = (sourceSets.main.output.files + [forgeRunModClassesDirectory])
+ .collect { it.canonicalFile } as Set
+ FileCollection dependencies = classpath.filter { !splitOutputs.contains(it.canonicalFile) }
+ includeMain ? files(forgeRunModClassesDirectory, dependencies) : dependencies
+}
+tasks.withType(JavaExec).configureEach { JavaExec runTask ->
+ if (runTask.name in ['runClient', 'runServer']) {
+ runTask.dependsOn prepareForgeRunModClasses
+ runTask.doFirst {
+ runTask.classpath = isolatedForgeClasspath(runTask.classpath, true)
+ runTask.environment 'MOD_CLASSES', "${mod_id}%%${forgeRunModClassesDirectory}"
}
}
}
// Clone ForgeGradle's generated launch at execution time so verification runs
-// use the same mappings and Java 8 runtime without exposing probe code to the
+// use the same mappings and Java 21 runtime without exposing probe code to the
// ordinary client or server classpath.
-def configureFromForgeRun = { JavaExec process, String runTaskName ->
+def configureFromForgeRun = { JavaExec process, String runTaskName, boolean includeMain = true ->
process.actions.clear()
process.dependsOn {
JavaExec run = tasks.getByName(runTaskName) as JavaExec
@@ -163,6 +260,8 @@ def configureFromForgeRun = { JavaExec process, String runTaskName ->
List originalJvmArgs = new ArrayList<>(run.jvmArgs)
Map originalSystemProperties = new LinkedHashMap<>(run.systemProperties)
Map originalEnvironment = new LinkedHashMap<>(run.environment)
+ Map originalConfiguredEnvironment = includeMain ? null :
+ new LinkedHashMap<>(runOptions.environment.get())
FileCollection originalClasspath = files(run.classpath.files)
String originalMinHeap = run.minHeapSize
String originalMaxHeap = run.maxHeapSize
@@ -174,17 +273,15 @@ def configureFromForgeRun = { JavaExec process, String runTaskName ->
run.jvmArgs(process.jvmArgs)
run.systemProperties(process.systemProperties)
run.environment(process.environment)
- Set splitMainOutputs = [
- tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile,
- sourceSets.main.output.resourcesDir
- ] as Set
- run.setClasspath(files(forge12DevelopmentOutput, run.classpath.filter {
- !splitMainOutputs.contains(it)
- }))
- if (pinnedJava8.isPresent()) run.executable = javaToolchains.launcherFor(exactJava8Spec).get().executablePath.asFile.path
+ String modClasses = includeMain ? "${mod_id}%%${forgeRunModClassesDirectory}" : ''
+ // Eclipse generation locks this map. Ordinary runs already have the merged output.
+ if (!includeMain) runOptions.environment.put('MOD_CLASSES', modClasses)
+ run.setClasspath(isolatedForgeClasspath(originalClasspath, includeMain))
+ run.environment('MOD_CLASSES', modClasses)
run.exec()
} finally {
runOptions.workingDir.set(originalConfiguredWorkingDir)
+ if (!includeMain) runOptions.environment.set(originalConfiguredEnvironment)
run.workingDir(originalWorkingDir)
run.setArgs(originalArgs)
run.setJvmArgs(originalJvmArgs)
@@ -225,24 +322,26 @@ repositories {
def coreLock = new Properties()
file('gradle/core-dependency.properties').withInputStream { coreLock.load(it) }
-def coreDirectory = file(providers.gradleProperty('buildingPiecesCoreDir').getOrElse('build/dependencies/core'))
+def coreDirectory = file(providers.gradleProperty('buildingPiecesCoreDir').getOrElse('../../SkysBuildingPieces263/SkysBuildingPieces'))
def localCoreVersion = providers.gradleProperty('buildingPiecesCoreVersion')
if (localCoreVersion.isPresent() && (System.getenv('CI') == 'true' || !providers.gradleProperty('buildingPiecesCoreDir').isPresent()))
throw new GradleException('The core-version override is local only and requires an explicit core directory; it is not permitted in CI.')
def effectiveCoreVersion = localCoreVersion.getOrElse(coreLock.version)
-def coreDevelopmentJar = new File(coreDirectory, "build/libs-dev/SkysBuildingPieces-${effectiveCoreVersion}-deobf.jar")
-configurations { bopRelease; developmentMappings }
+def coreDevelopmentJar = new File(coreDirectory, "build/libs/SkysBuildingPieces-${effectiveCoreVersion}.jar")
+configurations {
+ bopRelease
+ terraBlenderRelease
+ glitchCoreRelease
+}
repositories {
- maven {
- name = 'CurseMaven'
- url = 'https://cursemaven.com'
- content { includeGroup('curse.maven') }
- }
- maven {
- name = 'LegacyMcpMappings'
- url = 'https://maven.minecraftforge.net/'
- metadataSources { artifact() }
- content { includeModule('de.oceanlabs.mcp', 'mcp_stable') }
+ exclusiveContent {
+ forRepository {
+ maven {
+ name = 'Modrinth'
+ url = 'https://api.modrinth.com/maven'
+ }
+ }
+ filter { includeGroup('maven.modrinth') }
}
}
tasks.register('verifyCoreDependency') {
@@ -252,11 +351,26 @@ tasks.register('verifyCoreDependency') {
new File(coreDirectory, 'gradle.properties').withInputStream { actualCoreProperties.load(it) }
if (actualCoreProperties.mod_version != effectiveCoreVersion)
throw new GradleException('The selected core jar version does not match its source checkout.')
- if (!providers.gradleProperty('buildingPiecesCoreDir').isPresent() || System.getenv('CI') == 'true') {
+ if (!localCoreVersion.isPresent()) {
def git = new ProcessBuilder('git', '-C', coreDirectory.path, 'rev-parse', 'HEAD').start()
String actual = git.inputStream.getText('UTF-8').trim()
if (git.waitFor() != 0 || actual != coreLock.commit)
throw new GradleException('The core checkout does not match its pinned source commit.')
+ def status = new ProcessBuilder('git', '-C', coreDirectory.path,
+ 'status', '--porcelain', '--untracked-files=no').start()
+ String pending = status.inputStream.getText('UTF-8').trim()
+ if (status.waitFor() != 0 || pending) {
+ throw new GradleException('The pinned core checkout has uncommitted changes.')
+ }
+ [
+ (coreDevelopmentJar): coreLock.getProperty('development.sha256'),
+ (new File(coreDirectory, "build/libs/SkysBuildingPieces-${effectiveCoreVersion}.jar")): coreLock.sha256
+ ].each { artifact, expected ->
+ if (!artifact.isFile() || !expected || MessageDigest.getInstance('SHA-256')
+ .digest(artifact.bytes).encodeHex().toString().toUpperCase() != expected) {
+ throw new GradleException('The pinned core artifact changed: ' + artifact.name)
+ }
+ }
}
File dependency = configurations.bopRelease.singleFile
String hash = MessageDigest.getInstance('SHA-256').digest(Files.readAllBytes(dependency.toPath())).encodeHex().toString().toUpperCase()
@@ -266,47 +380,62 @@ tasks.register('verifyCoreDependency') {
tasks.withType(JavaCompile).configureEach { dependsOn 'verifyCoreDependency' }
dependencies {
- implementation files(coreDevelopmentJar)
- bopRelease "curse.maven:biomes-o-plenty-220318:${coreLock.bop_file}"
- developmentMappings "de.oceanlabs.mcp:mcp_stable:${project.mapping_version}@zip"
+ if (providers.gradleProperty('terraBlenderMainJar').isPresent()) {
+ terraBlenderRelease files(providers.gradleProperty('terraBlenderMainJar').get())
+ } else {
+ terraBlenderRelease "maven.modrinth:kkmrDlKT:${coreLock.terrablender_modrinth_version}"
+ }
+ if (providers.gradleProperty('glitchCoreMainJar').isPresent()) {
+ glitchCoreRelease files(providers.gradleProperty('glitchCoreMainJar').get())
+ } else {
+ glitchCoreRelease "maven.modrinth:s3dmwKy5:${coreLock.glitchcore_modrinth_version}"
+ }
+ compileOnly files(coreDevelopmentJar)
+ testImplementation files(coreDevelopmentJar)
+ testImplementation 'net.minecraftforge:eventbus-validator:7.0.1'
+ if (providers.gradleProperty('biomesOPlentyMainJar').isPresent()) {
+ bopRelease files(providers.gradleProperty('biomesOPlentyMainJar').get())
+ } else {
+ bopRelease "maven.modrinth:HXF82T3G:${coreLock.bop_modrinth_version}"
+ }
}
-// Legacy dependencies carry their own SRG access rules. Apply BOP's original
-// rules to the mapped development Minecraft jar; keep its release jar intact.
-def bopDevelopmentAccessRules = file('.gradle/dependencies/biomesoplenty_at.cfg')
+// Forge uses official names in packaged and development runs.
File bopJar = configurations.bopRelease.singleFile
-if (MessageDigest.getInstance('SHA-256').digest(Files.readAllBytes(bopJar.toPath()))
- .encodeHex().toString().toUpperCase() != coreLock.bop_sha256)
- throw new GradleException('The BOP dependency does not match the qualified jar.')
-def mappedMembers = [:]
-new ZipFile(configurations.developmentMappings.singleFile).withCloseable { archive ->
- ['fields.csv', 'methods.csv'].each { name ->
- archive.getInputStream(archive.getEntry(name)).withCloseable { input ->
- input.getText('UTF-8').readLines().drop(1).each { row ->
- def columns = row.split(',', 3)
- mappedMembers[columns[0]] = columns[1]
- }
- }
+File terraBlenderJar = configurations.terraBlenderRelease.singleFile
+File glitchCoreJar = configurations.glitchCoreRelease.singleFile
+def nativeBopDependencies = [
+ (bopJar): coreLock.bop_sha256,
+ (terraBlenderJar): coreLock.terrablender_sha256,
+ (glitchCoreJar): coreLock.glitchcore_sha256
+]
+nativeBopDependencies.each { artifact, expected ->
+ if (sha256Of(artifact) != expected) {
+ throw new GradleException('The pinned dependency changed: ' + artifact.name)
}
}
-new ZipFile(bopJar).withCloseable { archive ->
- def entry = archive.getEntry('META-INF/biomesoplenty_at.cfg')
- if (entry == null) throw new GradleException('BOP development access rules are missing.')
- bopDevelopmentAccessRules.parentFile.mkdirs()
- archive.getInputStream(entry).withCloseable { input ->
- String rules = input.getText('UTF-8').replaceAll(/\b(?:func|field)_\d+_[A-Za-z_]+\b/) { member ->
- if (!mappedMembers.containsKey(member)) throw new GradleException("Unmapped BOP access rule: ${member}")
- mappedMembers[member]
+ext.bopMainJar = bopJar
+ext.terraBlenderMainJar = terraBlenderJar
+ext.glitchCoreMainJar = glitchCoreJar
+ext.bopDevelopmentJar = bopJar
+ext.terraBlenderDevelopmentJar = terraBlenderJar
+ext.glitchCoreDevelopmentJar = glitchCoreJar
+def bopDevelopmentArchive = tasks.register('verifyNativeBopDependencies') {
+ dependsOn tasks.named('verifyCoreDependency')
+ inputs.files(nativeBopDependencies.keySet())
+ doLast {
+ nativeBopDependencies.each { artifact, expected ->
+ if (sha256Of(artifact) != expected) {
+ throw new GradleException('The pinned dependency changed: ' + artifact.name)
+ }
}
- bopDevelopmentAccessRules.setText(rules, 'UTF-8')
}
}
+ext.bopDevelopmentTask = bopDevelopmentArchive
+
dependencies {
implementation minecraft.dependency(
- "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") {
- accessTransformers.from(bopDevelopmentAccessRules)
- }
- compileOnly 'org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209'
+ "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}")
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2'
@@ -314,11 +443,39 @@ dependencies {
}
def integrationTestClasses = layout.buildDirectory.dir('integration-test-mod/classes')
+
+def sharedProbe = new File(coreDirectory,
+ 'src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/RuntimeProbe.java')
+def sharedProbeOutput = layout.buildDirectory.file('generated/addon-probe/RuntimeProbe.java')
+def prepareSharedProbe = tasks.register('prepareSharedAddonProbe') {
+ inputs.file(sharedProbe)
+ outputs.file(sharedProbeOutput)
+ doLast {
+ String source = sharedProbe.getText('UTF-8')
+ String method = 'public void started(ServerStartedEvent event) {'
+ if (source.indexOf(method) < 0 || source.indexOf(method) != source.lastIndexOf(method)) {
+ throw new GradleException('The pinned core probe has an unexpected startup method')
+ }
+ File output = sharedProbeOutput.get().asFile
+ output.parentFile.mkdirs()
+ // The BOP driver runs these larger audits across multiple ticks.
+ output.setText(source.replace(method, method + '''
+ String addonPhase = System.getProperty("skysbuildingpieces.integrationPhase", "");
+ if (addonPhase.equals("fresh") || addonPhase.equals("reload")
+ || addonPhase.startsWith("sylvester-baskets-") || addonPhase.startsWith("sylvester-items-")) {
+ return;
+ }
+'''), 'UTF-8')
+ }
+}
def compileIntegrationTestMod = tasks.register('compileIntegrationTestMod', JavaCompile) {
+ dependsOn prepareSharedProbe
group = 'verification'
dependsOn tasks.named('classes')
- source fileTree('src/integrationTest/java'), fileTree(new File(coreDirectory, 'src/integrationTest/java'))
- classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath)
+ source fileTree('src/integrationTest/java'), fileTree(new File(coreDirectory, 'src/integrationTest/java')) { exclude '**/RuntimeProbe.java' }
+ source sharedProbeOutput
+ source fileTree('src/bootstrapTest/java') { exclude '**/NativeBootstrap.java' }
+ classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath, sourceSets.main.runtimeClasspath)
destinationDirectory = integrationTestClasses
options.encoding = 'UTF-8'
}
@@ -330,29 +487,30 @@ def integrationTestModJar = tasks.register('integrationTestModJar', Jar) {
destinationDirectory = layout.buildDirectory.dir('integration-test-mod')
from integrationTestClasses
from 'src/integrationTest/resources'
- from new File(coreDirectory, 'src/integrationTest/resources')
-}
-
-def packagedIntegrationTestModJar = renamer.classes(integrationTestModJar) {
- map.from minecraft.dependency.toSrgFile
- libraries.from tasks.named('jar', Jar).flatMap { it.archiveFile }
- libraries.from sourceSets.main.compileClasspath
- output = layout.buildDirectory.file(
- 'integration-test-mod/skysbuildingpieces-integration-test-reobf.jar')
+ from(new File(coreDirectory, 'src/integrationTest/resources')) {
+ exclude 'META-INF/mods.toml', 'log4j2.xml'
+ }
}
tasks.register('preparePackagedIntegrationProbe') {
group = 'verification'
- description = 'Builds the reobfuscated probe used only in disposable packaged runtimes.'
- dependsOn packagedIntegrationTestModJar
+ description = 'Builds the probe used only in disposable packaged runtimes.'
+ dependsOn integrationTestModJar
}
-def integrationRunDirectory = layout.buildDirectory.dir('integration-test-run')
+def integrationRunName = providers.gradleProperty('bopIntegrationRunName').getOrElse('integration-test-run-' + UUID.randomUUID())
+if (!(integrationRunName ==~ /[a-z0-9-]+/)) {
+ throw new GradleException('Use a simple name for the integration run directory.')
+}
+def integrationRunDirectory = layout.buildDirectory.dir(integrationRunName)
tasks.register('prepareDevelopmentDependencies') {
+ dependsOn bopDevelopmentArchive
doLast {
project.copy {
- from configurations.bopRelease
+ from project.ext.bopDevelopmentJar
+ from project.ext.terraBlenderDevelopmentJar, project.ext.glitchCoreDevelopmentJar
+ from coreDevelopmentJar
into file("${developmentRunDirectory}/mods")
}
}
@@ -363,28 +521,42 @@ tasks.matching { it.name in ['runClient', 'runServer'] }.configureEach {
tasks.named('eclipse') { dependsOn 'prepareDevelopmentDependencies' }
def prepareIntegrationTest = tasks.register('prepareIntegrationTest') {
group = 'verification'
- dependsOn integrationTestModJar
+ dependsOn integrationTestModJar, bopDevelopmentArchive
doLast {
File runDirectory = integrationRunDirectory.get().asFile
- project.delete(runDirectory)
+ if (runDirectory.canonicalFile != new File(buildDir, integrationRunName).canonicalFile
+ || runDirectory.exists()) {
+ throw new GradleException('The integration checks require a new directory under build: ' + runDirectory)
+ }
File mods = new File(runDirectory, 'mods')
mods.mkdirs()
project.copy {
from integrationTestModJar.flatMap { it.archiveFile }
- from configurations.bopRelease
+ from project.ext.bopDevelopmentJar
+ from project.ext.terraBlenderDevelopmentJar, project.ext.glitchCoreDevelopmentJar
+ from coreDevelopmentJar
into mods
}
+ project.copy {
+ from 'src/bootstrapTest/defaultconfigs'
+ into runDirectory
+ }
+ // ModLauncher 4's hierarchy cache uses unsynchronised maps during construction. The dense development
+ // probe hit that race before BOP's subscriber loaded. Serialize this harness; packaged checks retain defaults.
+ new File(runDirectory, 'config/fml.toml').setText('''versionCheck=true
+splashscreen=true
+defaultConfigPath="defaultconfigs"
+maxThreads=1
+''', 'UTF-8')
new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8')
new File(runDirectory, 'server.properties').setText('''\
level-name=world
level-seed=skys-building-pieces-integration
-level-type=default
+level-type=minecraft:normal
online-mode=false
server-ip=127.0.0.1
server-port=0
generate-structures=false
-spawn-animals=false
-spawn-monsters=false
spawn-protection=0
max-tick-time=120000
''', 'UTF-8')
@@ -397,9 +569,15 @@ def createIntegrationProcess = { String phase, Object dependency ->
dependsOn dependency
workingDir integrationRunDirectory
systemProperty 'skysbuildingpieces.integrationPhase', phase.toLowerCase(Locale.ROOT)
- systemProperty 'log4j.configurationFile', new File(coreDirectory, 'src/integrationTest/resources/log4j2.xml').toURI().toString()
configureFromForgeRun(delegate, 'runServer')
doLast {
+ File runDirectory = integrationRunDirectory.get().asFile
+ project.ext.auditNativeBopServerLogs(runDirectory, phase.toLowerCase(Locale.ROOT))
+ String log = readForgeRuntimeLog(new File(runDirectory, 'logs/latest.log'))
+ if (log.find(/(?:\/|\[)(?:ERROR|FATAL)\]/) || log.contains('[STDERR')
+ || !fileTree(new File(runDirectory, 'crash-reports')).isEmpty()) {
+ throw new GradleException(phase + ' integration produced an unexpected failure')
+ }
File marker = new File(integrationRunDirectory.get().asFile,
'world/skysbuildingpieces-integration.properties')
if (!marker.isFile()) {
@@ -445,7 +623,6 @@ tasks.register('integrationClientProcess', JavaExec) {
dependsOn prepareIntegrationTest
workingDir integrationRunDirectory
systemProperty 'skysbuildingpieces.integrationPhase', 'client'
- systemProperty 'log4j.configurationFile', new File(coreDirectory, 'src/integrationTest/resources/log4j2.xml').toURI().toString()
configureFromForgeRun(delegate, 'runClient')
doLast {
if (!new File(integrationRunDirectory.get().asFile, 'building-pieces-client-pass.txt').isFile())
@@ -459,6 +636,8 @@ tasks.named('check') {
tasks.named('processResources', ProcessResources) {
filteringCharset = 'UTF-8'
+ inputs.property('archiveLineEndings', 'utf8-lf-with-toml')
+ from('artwork/skysbuildingpieces-bop-logo.png') { rename { 'logo.png' } }
inputs.properties([
version: project.version,
mcversion: project.minecraft_version,
@@ -466,17 +645,11 @@ tasks.named('processResources', ProcessResources) {
name: project.mod_name,
description: project.mod_description
])
- filesMatching('mcmod.info') {
- expand version: project.version, mcversion: project.minecraft_version,
- modid: project.mod_id, name: project.mod_name,
- description: project.mod_description
- }
+ filesMatching('META-INF/mods.toml') { expand project.properties }
filesMatching(archiveTextPatterns, normalizeArchiveLineEndings)
}
tasks.named('jar', Jar) {
- archiveClassifier = 'deobf'
- destinationDirectory = layout.buildDirectory.dir('libs-dev')
from(['LICENSE', 'LICENSE.spdx', 'NOTICE', 'BUILDINGBRICKS-MIT.txt', 'MINERALOGY-LGPL-3.0.txt', 'MINERALOGY-GPL-3.0.txt']) { into 'META-INF' }
manifest {
attributes([
@@ -484,19 +657,15 @@ tasks.named('jar', Jar) {
'Implementation-Title': base.archivesName.get(),
'Implementation-Version': project.version,
'Implementation-Vendor': project.mod_authors,
- 'Built-On-Java': '8',
- 'Built-On': "${project.minecraft_version}-${project.forge_version}"
+ 'Built-On-Java': project.java_version,
+ 'Built-On': "${project.minecraft_version}-${project.forge_version}",
+ 'MixinConfigs': mixinConfig
])
}
filesMatching(archiveTextPatterns, normalizeArchiveLineEndings)
}
-def releaseJar = renamer.classes(tasks.named('jar', Jar)) {
- map.from minecraft.dependency.toSrgFile
- libraries.from sourceSets.main.compileClasspath
- archiveClassifier = null
- output = layout.buildDirectory.file("libs/${base.archivesName.get()}-${project.version}.jar")
-}
+def releaseJar = tasks.named('jar', Jar)
tasks.named('sourcesJar', Jar) {
from(['LICENSE', 'LICENSE.spdx', 'NOTICE', 'BUILDINGBRICKS-MIT.txt', 'MINERALOGY-LGPL-3.0.txt', 'MINERALOGY-GPL-3.0.txt']) { into 'META-INF' }
@@ -507,10 +676,6 @@ tasks.named('javadocJar', Jar) {
filesMatching(archiveTextPatterns, normalizeArchiveLineEndings)
}
-['apiElements', 'runtimeElements'].each { configurationName ->
- configurations.named(configurationName) { artifacts.clear() }
- artifacts { add(configurationName, releaseJar) }
-}
tasks.named('assemble') {
dependsOn releaseJar, tasks.named('sourcesJar'), tasks.named('javadocJar')
}
@@ -526,12 +691,12 @@ def releaseFiles = provider {
tasks.register('verifyReleaseMetadata') {
group = 'verification'
doLast {
- if (project.minecraft_version != '1.12.2' || project.forge_version != '14.23.5.2859' ||
- project.mapping_channel != 'stable' || project.mapping_version != '39-1.12' ||
+ if (project.minecraft_version != '26.3' || project.forge_version != '66.0.2' ||
+ project.mapping_channel != 'official' || project.mapping_version != '26.3' ||
project.loader_name != 'forge' || project.loader_code != '1' ||
- project.java_version != '8' || project.java_toolchain_version != '8.0.502+7' ||
+ project.java_version != '25' || project.java_toolchain_version != '25.0.3+9' ||
project.mod_id != 'skysbuildingpiecesbop') {
- throw new GradleException('Unexpected 1.12.2 release metadata')
+ throw new GradleException('Unexpected 26.3 release metadata')
}
}
}
@@ -549,8 +714,16 @@ tasks.register('verifyReleaseArtifacts') {
}
ZipFile zip = new ZipFile(new File(libs, releaseFiles.get()[0]))
try {
+ def metadata = new java.util.jar.Manifest(zip.getInputStream(zip.getEntry('META-INF/MANIFEST.MF')))
+ if (metadata.mainAttributes.getValue('Built-On-Java') != project.java_version.toString()) {
+ throw new GradleException("The jar's Java label does not match its production target")
+ }
+ if (metadata.mainAttributes.getValue('MixinConfigs') != 'skysbuildingpiecesbop.mixins.json') {
+ throw new GradleException('The jar is missing its migration and basket mixins')
+ }
Set names = zip.entries().collect { it.name } as Set
- ['mcmod.info', 'META-INF/LICENSE', 'META-INF/LICENSE.spdx', 'META-INF/NOTICE',
+ ['skysbuildingpiecesbop.mixins.json', 'assets/skysbuildingpiecesbop/items/flower_basket.json',
+ 'META-INF/mods.toml', 'META-INF/LICENSE', 'META-INF/LICENSE.spdx', 'META-INF/NOTICE',
'zone/moddev/mc/skysbuildingpiecesbop/SkysBuildingPiecesBop.class', 'META-INF/BUILDINGBRICKS-MIT.txt',
'META-INF/MINERALOGY-LGPL-3.0.txt', 'META-INF/MINERALOGY-GPL-3.0.txt',
'assets/skysbuildingpiecesbop/blockstates/retired_rocks.json',
@@ -558,15 +731,21 @@ tasks.register('verifyReleaseArtifacts') {
'assets/skysbuildingpiecesbop/blockstates/jelled_poison.json',
'zone/moddev/mc/skysbuildingpiecesbop/RetiredDecorations.class',
'zone/moddev/mc/skysbuildingpiecesbop/basket/BasketItem.class',
- 'assets/skysbuildingpiecesbop/recipes/flower_basket.json',
+ 'data/skysbuildingpiecesbop/recipe/flower_basket.json',
+ 'data/skysbuildingpiecesbop/loot_table/blocks/jelled_poison.json',
+ 'data/minecraft/tags/block/supports_small_dripleaf.json',
+ 'data/minecraft/tags/block/mineable/pickaxe.json',
+ 'assets/skysbuildingpiecesbop/catalogue/legacy_world_states.json',
+ 'assets/skysbuildingpiecesbop/catalogue/legacy_biomes.json',
+ 'assets/skysbuildingpiecesbop/catalogue/legacy_items.json',
'assets/skysbuildingpiecesbop/models/item/flower_basket.json',
- 'assets/skysbuildingpiecesbop/textures/items/flower_basket_empty.png',
- 'assets/skysbuildingpiecesbop/textures/items/flower_basket_full.png'].each { required ->
+ 'assets/skysbuildingpiecesbop/textures/item/flower_basket_empty.png',
+ 'assets/skysbuildingpiecesbop/textures/item/flower_basket_full.png'].each { required ->
if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}")
}
['gabbro', 'limestone', 'limestone_smooth', 'shale', 'shale_smooth',
'siltstone', 'siltstone_smooth'].each { rock ->
- String texture = "assets/skysbuildingpiecesbop/textures/blocks/retired_${rock}.png"
+ String texture = "assets/skysbuildingpiecesbop/textures/block/retired_${rock}.png"
if (!names.contains(texture)) throw new GradleException("Release jar is missing ${texture}")
if (!Arrays.equals(zip.getInputStream(zip.getEntry(texture)).bytes,
Files.readAllBytes(file("src/main/resources/${texture}").toPath()))) {
@@ -578,6 +757,14 @@ tasks.register('verifyReleaseArtifacts') {
lower.tokenize('/').any { segment -> segment in ['agents.md', 'agent.md', 'agent-notes', 'agent_notes'] } ||
lower.contains('.codex') || lower.contains('.claude') ||
lower.contains('/test/') || lower.contains('gametest') ||
+ lower == 'meta-inf/coremods.json' || lower.startsWith('coremods/') ||
+ lower.startsWith('assets/skysbuildingpiecesbop/recipes/') ||
+ lower.startsWith('assets/skysbuildingpiecesbop/advancements/') ||
+ lower.startsWith('assets/skysbuildingpiecesbop/textures/blocks/') ||
+ lower.startsWith('assets/skysbuildingpiecesbop/textures/items/') ||
+ lower ==~ /data\/[^\/]+\/(recipes|advancements|loot_tables)\/.*/ ||
+ lower ==~ /data\/[^\/]+\/tags\/(blocks|items)\/.*/ ||
+ lower.startsWith('assets/skysbuildingpiecesbop/lang/') && lower.endsWith('.lang') ||
lower.startsWith('d' + ':' + '/') ||
lower.startsWith('c' + ':' + '/users/')
}
@@ -594,8 +781,8 @@ tasks.register('verifyReleaseArtifacts') {
if (!entry.directory) {
byte[] auditBytes = zip.getInputStream(entry).bytes
if (entry.name.endsWith('.class') &&
- (((auditBytes[6] & 255) << 8) | (auditBytes[7] & 255)) != 52) {
- throw new GradleException("Non-Java-8 production class: ${entry.name}")
+ (((auditBytes[6] & 255) << 8) | (auditBytes[7] & 255)) != 69) {
+ throw new GradleException("Non-Java-16 production class: ${entry.name}")
}
String content = new String(zip.getInputStream(entry).bytes,
StandardCharsets.ISO_8859_1).toLowerCase(Locale.ROOT)
@@ -614,11 +801,11 @@ tasks.register('verifyReleaseArtifacts') {
}
List localeFiles = [
- 'de_at.lang', 'de_au.lang', 'de_de.lang',
- 'en_ca.lang', 'en_en.lang', 'en_gb.lang', 'en_pt.lang', 'en_us.lang',
- 'es_es.lang', 'es_mx.lang', 'fr_ca.lang', 'fr_fr.lang',
- 'ja_jp.lang', 'ko_kr.lang', 'pt_br.lang', 'pt_pt.lang',
- 'ru_ru.lang', 'zh_cn.lang'
+ 'de_at.json', 'de_au.json', 'de_de.json',
+ 'en_ca.json', 'en_en.json', 'en_gb.json', 'en_pt.json', 'en_us.json',
+ 'es_es.json', 'es_mx.json', 'fr_ca.json', 'fr_fr.json',
+ 'ja_jp.json', 'ko_kr.json', 'pt_br.json', 'pt_pt.json',
+ 'ru_ru.json', 'zh_cn.json'
]
releaseFiles.get()[0..1].each { artifactName ->
ZipFile localizedZip = new ZipFile(new File(libs, artifactName))
@@ -706,10 +893,11 @@ eclipse {
def prepareEclipseResources = tasks.register('prepareEclipseResources') {
group = 'ide'
- dependsOn tasks.named('processResources')
+ dependsOn tasks.named('processResources'), prepareDevelopmentManifest
doLast {
project.copy {
from(layout.buildDirectory.dir('resources/main'))
+ from(developmentManifest) { into 'META-INF' }
into(layout.projectDirectory.dir('bin/main'))
}
}
@@ -747,16 +935,20 @@ tasks.register('isolateEclipseProductionRuns') {
File launch = file(name)
if (!launch.isFile()) throw new GradleException("ForgeGradle did not generate ${name}")
String contents = launch.getText('UTF-8')
- contents = contents.replace(
- 'key="MC_VERSION" value="${MC_VERSION}"',
- "key=\"MC_VERSION\" value=\"${project.minecraft_version}\"")
boolean production = name in ['runClient.launch', 'runServer.launch']
+ if (production) {
+ // Eclipse compiles into bin/main, alongside the processed resources.
+ String roots = "${mod_id}%%${projectDir}/bin/main"
+ contents = contents.replaceAll(/key="MOD_CLASSES" value="[^"]*"/) {
+ 'key="MOD_CLASSES" value="' + roots + '"'
+ }
+ }
if (production && !contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) {
contents = contents.replace('',
' ' +
System.lineSeparator() + '')
}
- ['--cache', '--metadata', '--to-srg', '--to-obf'].each { option ->
+ ['--cache', '--metadata'].each { option ->
String prefix = option + ' '
int start = contents.indexOf(prefix)
if (start < 0) throw new GradleException("${name} is missing ${option}")
@@ -788,16 +980,20 @@ tasks.register('verifyEclipseProductionClasspath') {
if (file(prefs.getProperty('connection.gradle.user.home', '')).canonicalFile != gradle.gradleUserHomeDir.canonicalFile) {
throw new GradleException('Eclipse Buildship uses the wrong Gradle home')
}
- File metadata = file('bin/main/mcmod.info')
- def parsed = new JsonSlurper().parseText(metadata.getText('UTF-8'))
- if (parsed[0].version != project.mod_version || parsed[0].mcversion != project.minecraft_version) {
- throw new GradleException('Eclipse output contains incorrect mcmod.info')
+ File metadata = file('bin/main/META-INF/mods.toml')
+ String text = metadata.isFile() ? metadata.getText('UTF-8') : ''
+ if (!text.contains("version=\"${project.mod_version}\"") ||
+ !text.contains("versionRange=\"${project.minecraft_version_range}\"")) {
+ throw new GradleException('Eclipse output contains incorrect mods.toml')
}
['runClient.launch', 'runServer.launch', 'runTestClient.launch', 'runTestServer.launch'].each { name ->
String contents = file(name).getText('UTF-8')
- if (contents.contains('${MC_VERSION}') ||
- !contents.contains("key=\"MC_VERSION\" value=\"${project.minecraft_version}\"")) {
- throw new GradleException("${name} does not define literal MC_VERSION=${project.minecraft_version}")
+ if (contents.contains('${MC_VERSION}')) {
+ throw new GradleException("${name} retains ForgeGradle's unresolved MC_VERSION token")
+ }
+ if (!contents.contains('--main net.minecraftforge.bootstrap.ForgeBootstrap') ||
+ !contents.contains("forge-${project.minecraft_version}-${project.forge_version}-metadata.zip")) {
+ throw new GradleException("${name} must use the native launcher and Minecraft ${project.minecraft_version}")
}
}
['runClient.launch', 'runServer.launch'].each { name ->
@@ -806,6 +1002,15 @@ tasks.register('verifyEclipseProductionClasspath') {
contents.contains('src/test') || contents.contains('bin/test') || contents.contains('junit-')) {
throw new GradleException("${name} exposes test code or dependencies")
}
+ def xml = new groovy.xml.XmlParser(false, false).parse(file(name))
+ String modClasses = xml.mapAttribute.find {
+ it.@key == 'org.eclipse.debug.core.environmentVariables'
+ }.mapEntry.find { it.@key == 'MOD_CLASSES' }.@value
+ List roots = modClasses.split(java.util.regex.Pattern.quote(File.pathSeparator)).toList()
+ if (roots.size() != 1 ||
+ roots[0].replace('\\', '/') != "${mod_id}%%${projectDir}/bin/main".replace('\\', '/')) {
+ throw new GradleException("${name} must use one merged production output in MOD_CLASSES")
+ }
}
}
}
@@ -814,9 +1019,20 @@ tasks.named('check') {
dependsOn tasks.named('verifyReleaseMetadata')
}
-apply from: 'gradle/recipe-advancements.gradle'
+tasks.register('verifyNativeRecipeConflicts', Exec) {
+ group = 'verification'
+ environment 'BUILDING_PIECES_CORE_RESOURCES', new File(coreDirectory, 'src/main/resources').absolutePath
+ commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'),
+ 'tools/test_recipe_conflicts.py'
+}
+tasks.named('check') { dependsOn tasks.named('verifyNativeRecipeConflicts') }
+
+// Recipes and unlocks use the native datapack tree.
ext.configureRecipeBookRun = configureFromForgeRun
-ext.recipeBookLogConfiguration = new File(coreDirectory, 'src/integrationTest/resources/log4j2.xml').toURI().toString()
-ext.recipeBookExtraMods = configurations.bopRelease
+ext.recipeBookExtraMods = files(project.ext.bopDevelopmentJar, project.ext.terraBlenderDevelopmentJar, project.ext.glitchCoreDevelopmentJar, coreDevelopmentJar)
apply from: 'gradle/recipe-book-verification.gradle'
+ext.coreDirectory = coreDirectory
+ext.coreDevelopmentJar = coreDevelopmentJar
+apply from: 'gradle/native-runtime-log.gradle'
+apply from: 'gradle/native-bootstrap.gradle'
diff --git a/ci-fixtures/tools/LICENSE-MAVENIZER.txt b/ci-fixtures/tools/LICENSE-MAVENIZER.txt
new file mode 100644
index 00000000..8000a6fa
--- /dev/null
+++ b/ci-fixtures/tools/LICENSE-MAVENIZER.txt
@@ -0,0 +1,504 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
+ USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random
+ Hacker.
+
+ , 1 April 1990
+ Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/ci-fixtures/tools/README.md b/ci-fixtures/tools/README.md
new file mode 100644
index 00000000..6607f17c
--- /dev/null
+++ b/ci-fixtures/tools/README.md
@@ -0,0 +1,47 @@
+# ForgeGradle 7 Mavenizer compatibility fixture
+
+This directory contains a build-only derivative of MinecraftForge's
+MinecraftMavenizer `0.5.21`. It is used only while ForgeGradle prepares the
+exact Forge `26.3-66.0.2` development dependency and is excluded from every
+OreSpawn publication artifact.
+
+## Provenance and licence
+
+- Upstream:
+- Exact source commit: `6968241ce7a0a902cdc1c534b976e8373a423091`
+- Upstream version: `0.5.21`
+- Licence: LGPL-2.1-only; see `LICENSE-MAVENIZER.txt`
+- OreSpawn derivative patch: `minecraft-mavenizer-0.5.21-orespawn-compat.patch`
+- Embedded/external rule manifest: `minecraft-source-compatibility.json`
+
+The patch adds a target-aware compatibility stage before Mavenizer recompiles
+decompiled sources. Rules run only for an exact Maven artifact listed in the
+manifest. Each rule requires one exact source file, one exact record
+declaration and one accessor in that record. Missing, partial, duplicate or
+ambiguous states fail preparation. Targets without an explicit rule set are
+left unchanged.
+
+Forge `26.3-66.0.2` compiles without source compatibility edits. Its explicit
+zero-rule entry proves that result is intentional and target-qualified rather
+than an accidental fall-through. A marker beside Mavenizer's output records
+the target and manifest SHA-256. Reprocessing the same source is idempotent.
+
+The derivative also propagates Gradle offline mode when the build sets
+`ORESPAWN_MAVENIZER_OFFLINE=true`. Mavenizer, IronAgeFurniture and Minecraft 26.3 all
+run and compile with Java 25.
+
+## Rebuild
+
+1. Clone the upstream repository and detach at
+ `6968241ce7a0a902cdc1c534b976e8373a423091`.
+2. Apply `minecraft-mavenizer-0.5.21-orespawn-compat.patch` with `git am`.
+3. Set `JAVA_HOME` to Temurin Java 25.
+4. Run `./gradlew clean build --no-daemon` (or `gradlew.bat` on Windows).
+5. Copy `build/libs/minecraft-mavenizer-0.5.21.jar` to
+ `minecraft-mavenizer-0.5.21-orespawn-compat.jar`.
+6. Run IronAgeFurniture's `verifyMavenizerCompatibilityFixture` task. The build script
+ contains the authoritative checksums and also verifies the embedded
+ manifest, Java class version and licence/provenance files.
+
+The sealed derivative was built with Eclipse Temurin `25.0.3+9` and Gradle
+`9.1.0` from the upstream wrapper.
diff --git a/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar
new file mode 100644
index 00000000..d74dabc7
Binary files /dev/null and b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar differ
diff --git a/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch
new file mode 100644
index 00000000..7d2ad63c
--- /dev/null
+++ b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch
@@ -0,0 +1,381 @@
+From debd1358466af4d21ae25079ade85486fb267762 Mon Sep 17 00:00:00 2001
+From: JohnBraham
+Date: Sat, 19 Sep 2026 16:12:24 +0100
+Subject: [PATCH] Qualify Mavenizer for Forge 26.3
+
+---
+ build.gradle | 2 +-
+ .../net/minecraftforge/mcmaven/cli/Main.java | 18 +-
+ .../minecraftforge/mcmaven/cli/MavenTask.java | 3 +
+ .../mcmaven/impl/util/ProcessUtils.java | 2 +
+ .../impl/util/SourceCompatibilityPatcher.java | 247 ++++++++++++++++++
+ .../minecraft-source-compatibility.json | 9 +
+ 6 files changed, 279 insertions(+), 2 deletions(-)
+ create mode 100644 src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java
+ create mode 100644 src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json
+
+diff --git a/build.gradle b/build.gradle
+index 439c211..9d99d81 100644
+--- a/build.gradle
++++ b/build.gradle
+@@ -17,7 +17,7 @@ plugins {
+ gradleutils.displayName = 'Minecraft Mavenizer'
+ description = 'A pure-blooded Java tool to generate a maven repository for Minecraft artifacts.'
+ group = 'net.minecraftforge'
+-version = gitversion.tagOffset
++version = '0.5.21'
+
+ println "Version: $version"
+
+diff --git a/src/main/java/net/minecraftforge/mcmaven/cli/Main.java b/src/main/java/net/minecraftforge/mcmaven/cli/Main.java
+index eeda8a0..ad0fe93 100644
+--- a/src/main/java/net/minecraftforge/mcmaven/cli/Main.java
++++ b/src/main/java/net/minecraftforge/mcmaven/cli/Main.java
+@@ -12,6 +12,7 @@ import static net.minecraftforge.mcmaven.impl.Mavenizer.LOGGER;
+
+ import java.time.Duration;
+ import java.util.ArrayList;
++import java.util.Arrays;
+
+ public class Main {
+ private static final String DISPLAY_NAME = "Minecraft Mavenizer";
+@@ -20,6 +21,8 @@ public class Main {
+ try {
+ LOGGER.capture();
+ LOGGER.info(JarVersionInfo.of(DISPLAY_NAME, Main.class).implementation());
++ LOGGER.info("OreSpawn Mavenizer compatibility runtime: Java " + Runtime.version()
++ + " (" + System.getProperty("java.vendor") + ")");
+ run(args);
+ } catch (Throwable e) {
+ LOGGER.release();
+@@ -36,7 +39,8 @@ public class Main {
+ LOGGER.getInfo().printf(", took %d:%02d.%03d%n", time.toMinutesPart(), time.toSecondsPart(), time.toMillisPart());
+ }
+
+- private static void run(String[] args) throws Exception {
++ private static void run(String[] suppliedArgs) throws Exception {
++ var args = withOfflinePropagation(suppliedArgs);
+ var parser = new OptionParser();
+ parser.allowsUnrecognizedOptions();
+ var tasks = Tasks.values();
+@@ -88,4 +92,16 @@ public class Main {
+ Tasks.MAVEN.callback.run(args, false);
+ }
+ }
++
++ private static String[] withOfflinePropagation(String[] suppliedArgs) {
++ if (!"true".equalsIgnoreCase(System.getenv("ORESPAWN_MAVENIZER_OFFLINE"))
++ || Arrays.asList(suppliedArgs).contains("--offline")) {
++ return suppliedArgs;
++ }
++
++ var propagated = Arrays.copyOf(suppliedArgs, suppliedArgs.length + 1);
++ propagated[suppliedArgs.length] = "--offline";
++ LOGGER.info("OreSpawn Mavenizer compatibility: propagated Gradle offline mode");
++ return propagated;
++ }
+ }
+diff --git a/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java b/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java
+index 825820e..39bb9b3 100644
+--- a/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java
++++ b/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java
+@@ -21,6 +21,7 @@ import net.minecraftforge.mcmaven.impl.cache.Cache;
+ import net.minecraftforge.mcmaven.impl.mappings.Mappings;
+ import net.minecraftforge.mcmaven.impl.util.Artifact;
+ import net.minecraftforge.mcmaven.impl.util.Constants;
++import net.minecraftforge.mcmaven.impl.util.SourceCompatibilityPatcher;
+
+ import static net.minecraftforge.mcmaven.impl.Mavenizer.LOGGER;
+
+@@ -194,6 +195,8 @@ class MavenTask {
+ if (artifact.getVersion() == null)
+ artifact = artifact.withVersion(options.valueOf(versionO));
+
++ SourceCompatibilityPatcher.selectTarget(artifact);
++
+ var mappings = getMappings(options, mappingsO, parchmentO);
+
+ var foreignRepositories = new HashMap();
+diff --git a/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java b/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java
+index 9fa2888..d8c14fd 100644
+--- a/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java
++++ b/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java
+@@ -305,6 +305,8 @@ public final class ProcessUtils {
+ throw new RuntimeException("Failed to extract source jar: " + sourcesJar.getAbsolutePath(), e);
+ }
+
++ SourceCompatibilityPatcher.apply(sourcesOutput.toPath(), outputJar.toPath());
++
+ // track source files
+ var sourcePath = new StringBuilder();
+ var nonSourceFiles = new ArrayList();
+diff --git a/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java b/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java
+new file mode 100644
+index 0000000..4d72d34
+--- /dev/null
++++ b/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java
+@@ -0,0 +1,247 @@
++/*
++ * Copyright (c) Forge Development LLC and contributors
++ * SPDX-License-Identifier: LGPL-2.1-only
++ */
++package net.minecraftforge.mcmaven.impl.util;
++
++import com.google.gson.Gson;
++import java.io.IOException;
++import java.io.InputStream;
++import java.nio.charset.StandardCharsets;
++import java.nio.file.Files;
++import java.nio.file.Path;
++import java.nio.file.StandardCopyOption;
++import java.security.MessageDigest;
++import java.security.NoSuchAlgorithmException;
++import java.util.HashSet;
++import java.util.LinkedHashMap;
++import java.util.List;
++import java.util.Map;
++import java.util.Objects;
++import java.util.stream.Collectors;
++
++import static net.minecraftforge.mcmaven.impl.Mavenizer.LOGGER;
++
++/** Applies exact, target-scoped source compatibility rules before recompilation. */
++public final class SourceCompatibilityPatcher {
++ public static final String MANIFEST_RESOURCE = "META-INF/orespawn/minecraft-source-compatibility.json";
++ private static final Gson GSON = new Gson();
++ private static volatile String selectedArtifact;
++
++ private SourceCompatibilityPatcher() {}
++
++ public static void selectTarget(Artifact artifact) {
++ selectedArtifact = artifact.getDescriptor();
++ }
++
++ public static void apply(Path sourceRoot, Path outputJar) {
++ var artifact = selectedArtifact;
++ if (artifact == null)
++ throw new IllegalStateException("No Mavenizer target artifact was selected before source recompilation");
++
++ var manifestBytes = readManifest();
++ var manifestHash = sha256(manifestBytes);
++ var manifest = GSON.fromJson(new String(manifestBytes, StandardCharsets.UTF_8), RuleManifest.class);
++ if (manifest == null || manifest.schema != 1 || manifest.targets == null)
++ throw new IllegalStateException("Unsupported or incomplete OreSpawn Mavenizer compatibility manifest");
++
++ var target = manifest.targets.get(artifact);
++ if (target == null) {
++ LOGGER.info("OreSpawn Mavenizer compatibility: no rules for " + artifact);
++ return;
++ }
++
++ validateTarget(target);
++ if (target.expectedApplications == 0) {
++ writeMarker(outputJar, artifact, manifestHash, target.rules);
++ LOGGER.info("OreSpawn Mavenizer compatibility: applied 0 rule(s) for " + artifact
++ + " (explicit no-op) manifest SHA-256 " + manifestHash);
++ return;
++ }
++ var sourceByFile = new LinkedHashMap();
++ var present = 0;
++ var absent = 0;
++ for (var rule : target.rules) {
++ var file = sourceRoot.resolve(rule.file).normalize();
++ if (!file.startsWith(sourceRoot.normalize()))
++ throw new IllegalStateException("Compatibility rule escapes source root: " + rule.id);
++ if (!Files.isRegularFile(file))
++ throw new IllegalStateException("Compatibility source file is missing for " + rule.id + ": " + rule.file);
++
++ var source = sourceByFile.computeIfAbsent(file,
++ ignored -> readUtf8(file).replace("\r\n", "\n"));
++ requireCount(source, rule.recordDeclaration, 1, "record declaration", rule);
++ var accessorCount = countInRecord(source, rule);
++ if (accessorCount == 1)
++ present++;
++ else if (accessorCount == 0)
++ absent++;
++ else
++ throw new IllegalStateException("Ambiguous accessor match for " + rule.id + ": expected at most 1, found " + accessorCount);
++ }
++
++ if (present != 0 && absent != 0)
++ throw new IllegalStateException("Partial compatibility state for " + artifact + ": " + present + " present, " + absent + " absent");
++
++ if (present == target.expectedApplications) {
++ for (var rule : target.rules) {
++ var file = sourceRoot.resolve(rule.file).normalize();
++ var patched = replaceInRecord(sourceByFile.get(file), rule);
++ sourceByFile.put(file, patched);
++ }
++ sourceByFile.forEach(SourceCompatibilityPatcher::writeUtf8);
++ writeMarker(outputJar, artifact, manifestHash, target.rules);
++ LOGGER.info("OreSpawn Mavenizer compatibility: applied " + present + " rule(s) for " + artifact
++ + " [" + ruleIds(target.rules) + "] manifest SHA-256 " + manifestHash);
++ } else if (absent == target.expectedApplications) {
++ writeMarker(outputJar, artifact, manifestHash, target.rules);
++ LOGGER.info("OreSpawn Mavenizer compatibility: verified " + absent + " rule(s) already applied for " + artifact
++ + " [" + ruleIds(target.rules) + "] manifest SHA-256 " + manifestHash);
++ } else {
++ throw new IllegalStateException("Compatibility manifest expected " + target.expectedApplications
++ + " applications for " + artifact + " but defines " + target.rules.size());
++ }
++ }
++
++ private static void validateTarget(TargetRuleSet target) {
++ if (target.expectedApplications < 0 || target.rules == null
++ || target.rules.size() != target.expectedApplications) {
++ throw new IllegalStateException("Compatibility target has an invalid expected application count");
++ }
++ var ids = new HashSet();
++ for (var rule : target.rules) {
++ Objects.requireNonNull(rule.id, "Compatibility rule id");
++ Objects.requireNonNull(rule.file, "Compatibility rule file");
++ Objects.requireNonNull(rule.recordDeclaration, "Compatibility record declaration");
++ Objects.requireNonNull(rule.accessor, "Compatibility accessor");
++ if (!ids.add(rule.id))
++ throw new IllegalStateException("Duplicate compatibility rule id: " + rule.id);
++ }
++ }
++
++ private static byte[] readManifest() {
++ try (InputStream in = SourceCompatibilityPatcher.class.getClassLoader().getResourceAsStream(MANIFEST_RESOURCE)) {
++ if (in == null)
++ throw new IllegalStateException("Missing embedded compatibility manifest: " + MANIFEST_RESOURCE);
++ return in.readAllBytes();
++ } catch (IOException e) {
++ throw new IllegalStateException("Could not read embedded compatibility manifest", e);
++ }
++ }
++
++ private static String readUtf8(Path file) {
++ try {
++ return Files.readString(file, StandardCharsets.UTF_8);
++ } catch (IOException e) {
++ throw new IllegalStateException("Could not read compatibility source " + file, e);
++ }
++ }
++
++ private static void writeUtf8(Path file, String content) {
++ try {
++ Files.writeString(file, content, StandardCharsets.UTF_8);
++ } catch (IOException e) {
++ throw new IllegalStateException("Could not write compatibility source " + file, e);
++ }
++ }
++
++ private static void writeMarker(Path outputJar, String artifact, String manifestHash, List rules) {
++ var marker = Path.of(outputJar.toString() + ".orespawn-compatibility");
++ var temp = Path.of(marker.toString() + ".tmp");
++ var content = "artifact=" + artifact + "\nmanifestSha256=" + manifestHash + "\nrules=" + ruleIds(rules) + "\n";
++ try {
++ Files.writeString(temp, content, StandardCharsets.UTF_8);
++ try {
++ Files.move(temp, marker, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
++ } catch (java.nio.file.AtomicMoveNotSupportedException ignored) {
++ Files.move(temp, marker, StandardCopyOption.REPLACE_EXISTING);
++ }
++ } catch (IOException e) {
++ throw new IllegalStateException("Could not write compatibility marker " + marker, e);
++ }
++ }
++
++ private static String ruleIds(List rules) {
++ return rules.stream().map(rule -> rule.id).collect(Collectors.joining(","));
++ }
++
++ private static void requireCount(String source, String needle, int expected, String description, Rule rule) {
++ var actual = count(source, needle);
++ if (actual != expected)
++ throw new IllegalStateException("Invalid " + description + " match for " + rule.id
++ + ": expected " + expected + ", found " + actual);
++ }
++
++ private static int count(String source, String needle) {
++ var count = 0;
++ var from = 0;
++ while ((from = source.indexOf(needle, from)) >= 0) {
++ count++;
++ from += needle.length();
++ }
++ return count;
++ }
++
++ private static int countInRecord(String source, Rule rule) {
++ var bounds = recordBounds(source, rule);
++ return count(source.substring(bounds[0], bounds[1]), rule.accessor);
++ }
++
++ private static String replaceInRecord(String source, Rule rule) {
++ var bounds = recordBounds(source, rule);
++ var body = source.substring(bounds[0], bounds[1]);
++ var count = count(body, rule.accessor);
++ if (count != 1)
++ throw new IllegalStateException("Accessor state changed while applying " + rule.id
++ + ": expected 1, found " + count);
++ var patchedBody = body.replace(rule.accessor, "");
++ if (count(patchedBody, rule.accessor) != 0)
++ throw new IllegalStateException("Accessor remained after applying " + rule.id);
++ return source.substring(0, bounds[0]) + patchedBody + source.substring(bounds[1]);
++ }
++
++ private static int[] recordBounds(String source, Rule rule) {
++ var declaration = source.indexOf(rule.recordDeclaration);
++ var open = source.indexOf('{', declaration + rule.recordDeclaration.length());
++ if (declaration < 0 || open < 0)
++ throw new IllegalStateException("Could not locate record body for " + rule.id);
++ var depth = 0;
++ for (var index = open; index < source.length(); index++) {
++ var character = source.charAt(index);
++ if (character == '{')
++ depth++;
++ else if (character == '}' && --depth == 0)
++ return new int[] {open, index + 1};
++ }
++ throw new IllegalStateException("Unterminated record body for " + rule.id);
++ }
++
++ private static String sha256(byte[] bytes) {
++ try {
++ var digest = MessageDigest.getInstance("SHA-256").digest(bytes);
++ var result = new StringBuilder(digest.length * 2);
++ for (byte value : digest)
++ result.append(String.format("%02X", value));
++ return result.toString();
++ } catch (NoSuchAlgorithmException e) {
++ throw new IllegalStateException("SHA-256 is unavailable", e);
++ }
++ }
++
++ private static final class RuleManifest {
++ int schema;
++ Map targets;
++ }
++
++ private static final class TargetRuleSet {
++ int expectedApplications;
++ List rules;
++ }
++
++ private static final class Rule {
++ String id;
++ String file;
++ String recordDeclaration;
++ String accessor;
++ }
++}
+diff --git a/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json b/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json
+new file mode 100644
+index 0000000..3451901
+--- /dev/null
++++ b/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json
+@@ -0,0 +1,9 @@
++{
++ "schema": 1,
++ "targets": {
++ "net.minecraftforge:forge:26.3-66.0.2": {
++ "expectedApplications": 0,
++ "rules": []
++ }
++ }
++}
+--
+2.55.0.windows.3
diff --git a/ci-fixtures/tools/minecraft-source-compatibility.json b/ci-fixtures/tools/minecraft-source-compatibility.json
new file mode 100644
index 00000000..f5105e6d
--- /dev/null
+++ b/ci-fixtures/tools/minecraft-source-compatibility.json
@@ -0,0 +1,9 @@
+{
+ "schema": 1,
+ "targets": {
+ "net.minecraftforge:forge:26.3-66.0.2": {
+ "expectedApplications": 0,
+ "rules": []
+ }
+ }
+}
diff --git a/docs/BUILDING.md b/docs/BUILDING.md
index a585a4aa..b5ec015f 100644
--- a/docs/BUILDING.md
+++ b/docs/BUILDING.md
@@ -1,22 +1,26 @@
# Building from source
-Use Java 17 for Gradle and Temurin 8.0.502+7 for production. Set
-`SBP_JAVA8_HOME` to the Java 8 installation. The wrapper uses Gradle 9.6.1
-and ForgeGradle 7.0.34 for Minecraft 1.12.2.
+Use Temurin 25.0.3+9 to build and run the mod. Set `SBP_JAVA25_HOME` to that
+installation to select the exact toolchain. The wrapper uses Gradle 9.7.1 and
+ForgeGradle 7.0.34 for Minecraft 26.3 and Forge 66.0.2.
-Check out Building Pieces 0.3.8.112021 at the exact release commit in
-`gradle/core-dependency.properties`, then run its `gradlew jar` task.
+The runtime log checks use Python 3.11 or later. If `python` is not on your
+path, pass `-PpythonExecutable=` to Gradle.
+
+Check out Building Pieces 0.3.9.2603001 at the exact source commit in
+`gradle/core-dependency.properties`, then run its `gradlew assemble` task.
Build this add-on with:
```text
gradlew check build javadoc verifyReleaseArtifacts writeReleaseChecksums verifyEclipseProductionClasspath -PbuildingPiecesCoreDir=
```
-The development dependency is the core's `build/libs-dev` jar, not a shaded
-copy or a published Maven dependency. Packaged games use both normal main
-jars. BOP is fetched as a checksum-verified dependency and is not bundled.
+Development and packaged runs both use the core's normal jar from
+`build/libs`. The add-on does not bundle any core classes. The build fetches BOP, TerraBlender and GlitchCore from Modrinth Maven
+and checks their hashes. The add-on uses these separate jars.
-CI builds the pinned core commit before building the add-on. A local-only
+The normal build checks both core jar hashes, its source commit and its clean
+working tree. CI builds that pinned commit before building the add-on. A local-only
`buildingPiecesCoreVersion` override is available for testing another core
candidate; it requires an explicit core directory and is rejected in CI.
@@ -26,11 +30,45 @@ build-only runtime probe. Release preparation is covered in [the release guide](
## Recipe-book resources
-The build generates unlock advancements from `src/main/recipe-book/recipes.json`.
-The runtime checks compare those entries with the registered recipes and exercise
+Recipes and unlock advancements live under `src/main/resources/data`. The
+runtime checks compare those entries with the registered recipes and exercise
inventory discovery, crafting-table discovery and recipe-book persistence.
Optional catalogue entries load only when their recipe exists.
-Recipe unlocks have no shared advancement parent. Minecraft 1.12 walks that
-visibility tree recursively when loading completed progress, which can overflow
-with a large catalogue. The inventory and crafting unlock criteria are unchanged.
+Recipe unlocks have no shared advancement parent. This keeps a large catalogue
+from creating a deeply nested visibility tree. Optional recipes and their
+advancements use matching Forge conditions.
+
+## Runtime checks
+
+`integrationTest` runs the shared core placement checks together with the BOP
+material, basket, decoration and migration checks, then reloads the saved world.
+The accepted 1.12, 1.14, 1.15, 1.16, 1.17 and 1.18 jars produced the sealed worlds in
+`src/testFixtures`; the forward tests copy those worlds before starting the
+native Forge 26.3 server.
+Older synthetic fixture writers are no longer used to create pre-flattening saves.
+
+`nativeClientTest` checks the baked models and basket GUI through real client
+packets. Its `bopClientPhase` property also accepts `client-basket-disconnect`
+and `client-basket-disconnect-reload`; run those in that order to check a player
+disconnecting with the basket open. All these runs use disposable build folders.
+
+The development integration harness uses one mod-loading thread. Packaged checks
+retain Forge's normal loading settings. Unexpected errors or crashes fail either
+profile; the native Forge controls must complete without severe log messages.
+
+`nativeInstalledBopPolicyTest` checks all 33 materials with replacement off and
+on, including placed pieces and inventories. It uses a labelled test fixture,
+which is excluded from ordinary launches and release jars. `check` includes it.
+
+For combined native checks, `-PbopNativeCompanions=true` adds the exact Grass
+Slabs, Mineralogy and OreSpawn jars listed in `gradle/optional-runtime.properties`.
+Their hashes are checked before copying. These profiles
+remain separate from ordinary client and server runs.
+Run `nativeClientTest` once, then repeat with `-PbopClientReload=true` to check
+the same saved client world. The second run refuses changed test or dependency
+jars. Disconnect tests use their separate `bopClientPhase` values instead.
+
+Hosted checks build the pinned Grass Slabs and Building Pieces sources before
+the add-on. They run compilation, unit tests and artifact checks. Gameplay,
+packaged launches and world upgrades are qualified locally.
diff --git a/docs/FLOWER-BASKET.md b/docs/FLOWER-BASKET.md
index 7569a0cf..439b3102 100644
--- a/docs/FLOWER-BASKET.md
+++ b/docs/FLOWER-BASKET.md
@@ -5,8 +5,8 @@ its contents when closed, dropped or stored in a chest. Right-click with the
basket in either hand to open it.
The basket accepts plant block items, including saplings, flowers, leaves,
-mushrooms, vines and cactus. It is not a general-purpose bag: seeds and unrelated
-items are not accepted. Baskets cannot be put inside other baskets.
+mushrooms, vines and cactus. Crop seeds that place plant blocks also fit. It is
+not a general-purpose bag, and baskets cannot be put inside other baskets.
Suitable plants picked up from the ground go into the first basket in your main
inventory, working from the first hotbar slot onwards. Existing stacks are filled
@@ -27,7 +27,7 @@ S S
SSS
```
-Sticks registered as `stickWood` are accepted. The basket has no durability.
+Sticks in the wooden-rods tag are accepted. The basket has no durability.
## Older worlds
diff --git a/docs/RECIPES.md b/docs/RECIPES.md
index abfe778e..3c09fcac 100644
--- a/docs/RECIPES.md
+++ b/docs/RECIPES.md
@@ -1,6 +1,6 @@
# Crafting
-Use core 0.3.8.112021 for ordinary crafting recipes. No template or cutting tool
+Use core 0.3.8.120011 for ordinary crafting recipes. No template or cutting tool
is required. All ingredients must be the same material, including wood type.
- Three full blocks in a row make six horizontal slabs.
@@ -19,7 +19,7 @@ Steps can also be rotated one for one; horizontal and vertical step items
both work in corner recipes. Use two of the same item type rather than mixing
horizontal and vertical items in one recipe.
-The core registers the recipes for every installed catalogue. This add-on
+Recipes use the installed material catalogue. This add-on
does not add templates, wooden walls, fences, gates or terrain generation.
Use BOP's native fences and gates. BOP materials in this catalogue do not
have pane recipes.
diff --git a/docs/RELEASE-0.2.4.114041.md b/docs/RELEASE-0.2.4.114041.md
new file mode 100644
index 00000000..4c6f4ac6
--- /dev/null
+++ b/docs/RELEASE-0.2.4.114041.md
@@ -0,0 +1,18 @@
+# 0.2.4.114041
+
+The Minecraft 1.14.4 port keeps the familiar pieces and placement, with
+native waterlogging and BOP's own slabs, stairs and walls wherever they fit.
+
+Cut retired rock slabs from two full blocks placed diagonally, and cut their
+stairs from slabs. These cuts keep separate from Mineralogy's native recipes.
+
+Older BOP pieces keep their saved orientation, counts and custom data. Materials
+that BOP removed use similar current woods or coloured terracotta. Flower
+Baskets keep their slots and contents, with old plant names converted before
+loading. See [world upgrades](WORLD-UPGRADES.md) for the replacements and limits.
+
+Supported old BOP terrain, plants, stored items and biome boundaries are
+recovered too. Removed biomes use similar current biomes; snowy tundra keeps a
+snowy replacement. Unrelated mods still need their own upgrade support.
+
+Back up your world and test a copy before upgrading.
diff --git a/docs/RELEASE-0.2.4.115021.md b/docs/RELEASE-0.2.4.115021.md
new file mode 100644
index 00000000..ccdf36a4
--- /dev/null
+++ b/docs/RELEASE-0.2.4.115021.md
@@ -0,0 +1,15 @@
+# 0.2.4.115021
+
+This local candidate brings the BOP building pieces and Flower Basket to
+Minecraft 1.15.2 and Forge 31.2.57. It requires Building Pieces 0.3.8.115021 and
+BOP 10.0.0.366.
+
+Ethereal wood uses birch replacements, with white cherry leaves and saplings.
+Dried sand becomes dried salt and barley becomes tall wheat. Removed Nether
+and End biomes use replacements in their original dimensions. Saved pieces,
+inventories and Flower Basket contents retain their orientations, counts and
+custom data.
+
+Back up the world and test a separate copy before upgrading. See the world
+[upgrade guide](WORLD-UPGRADES.md) for supported replacements and limits.
+Manual testing is pending.
diff --git a/docs/RELEASE-0.2.4.116051.md b/docs/RELEASE-0.2.4.116051.md
new file mode 100644
index 00000000..fad14ddd
--- /dev/null
+++ b/docs/RELEASE-0.2.4.116051.md
@@ -0,0 +1,13 @@
+# 0.2.4.116051
+
+This local candidate brings the BOP building pieces and Flower Basket to
+Minecraft 1.16.5 and Forge 36.2.34. It requires Building Pieces 0.3.8.116051
+and BOP 13.1.0.488.
+
+The piece catalogue and saved identities stay the same. Plants and biomes
+removed or renamed by BOP use the replacements listed in the
+[upgrade guide](WORLD-UPGRADES.md). Basket contents keep their counts and
+custom data.
+
+Back up your world and test a separate copy before upgrading. Manual testing
+is pending.
diff --git a/docs/RELEASE-0.2.4.117011.md b/docs/RELEASE-0.2.4.117011.md
new file mode 100644
index 00000000..11cf298f
--- /dev/null
+++ b/docs/RELEASE-0.2.4.117011.md
@@ -0,0 +1,12 @@
+# Minecraft 1.17.1
+
+This version uses Forge 37.1.1, Java 16, Building Pieces 0.3.8.117011 and
+Biomes O' Plenty 14.0.0.43. Install the matching main jars on both sides.
+
+The existing pieces and Flower Basket keep their saved identities, orientations,
+waterlogging and stored contents. Recipes appear in the recipe book as their
+ingredients are collected. Nether crystals removed by BOP become rose quartz
+clusters or full blocks, keeping their attachment direction and item data.
+
+Back up an existing world and try a copy first. See [world upgrades](WORLD-UPGRADES.md)
+for the supported replacements and the limits of older BOP content recovery.
diff --git a/docs/RELEASE-0.2.4.118021.md b/docs/RELEASE-0.2.4.118021.md
new file mode 100644
index 00000000..e4a7c1af
--- /dev/null
+++ b/docs/RELEASE-0.2.4.118021.md
@@ -0,0 +1,8 @@
+# Minecraft 1.18.2
+
+Use Forge 40.3.0, Java 17, Building Pieces 0.3.8.118021, Biomes O' Plenty
+16.0.0.134 and TerraBlender 1.2.0.126. Install matching main jars on both sides.
+
+Building pieces keep their materials, orientations and waterlogging. Flower
+Baskets keep their contents. Removed roots and biomes use the replacements in
+the [world-upgrade guide](WORLD-UPGRADES.md). Test a copy before upgrading.
diff --git a/docs/RELEASE-0.2.4.119041.md b/docs/RELEASE-0.2.4.119041.md
new file mode 100644
index 00000000..a3e3f65a
--- /dev/null
+++ b/docs/RELEASE-0.2.4.119041.md
@@ -0,0 +1,9 @@
+# Minecraft 1.19.4
+
+Use Forge 45.4.0, Java 17, Building Pieces 0.3.8.119041, Biomes O' Plenty
+17.3.0.593 and TerraBlender 2.3.0.22. Install matching main jars on both sides.
+
+Building pieces keep their materials, orientations and waterlogging. Flower
+Baskets keep their contents. Removed mud pieces use vanilla equivalents;
+rooted sand and removed biomes use the replacements in the
+[world-upgrade guide](WORLD-UPGRADES.md). Test a copy before upgrading.
diff --git a/docs/RELEASE-0.2.4.120011.md b/docs/RELEASE-0.2.4.120011.md
new file mode 100644
index 00000000..ee7ed107
--- /dev/null
+++ b/docs/RELEASE-0.2.4.120011.md
@@ -0,0 +1,10 @@
+# Building Pieces BOP for Minecraft 1.20.1
+
+Use Forge 47.4.10, Java 17 and Building Pieces 0.3.8.120011. Install Biomes
+O' Plenty 19.0.0.96, TerraBlender 3.0.1.10 and GlitchCore 0.0.1.1 on both the
+client and server.
+
+Existing pieces and Flower Baskets keep their shapes, slots and contents.
+Older BOP cherry blocks use Minecraft's cherry equivalents. Snowblossom and
+the matching maple colours take the place of retired plants. See the
+[world upgrade guide](WORLD-UPGRADES.md) for the other replacements.
diff --git a/docs/TRYING-THE-BETA.md b/docs/TRYING-THE-BETA.md
index e1a5a73d..7fcec2d1 100644
--- a/docs/TRYING-THE-BETA.md
+++ b/docs/TRYING-THE-BETA.md
@@ -1,21 +1,26 @@
# Trying the beta
-Install the main jars for the core, this add-on and BOP on the client and
-server. Do not put sources or Javadoc jars in the mods folder.
+Install the Minecraft 26.3 main jars for Building Pieces 0.3.9.2603001,
+this add-on, BOP 26.3.0.0.16, TerraBlender 26.3.0.0.2 and GlitchCore 26.3.0.0.0 on
+the client and server. Use a new world or a backup copy.
-- Check gem and wood piece names in English and your usual game language.
- Sapphire Wall and Redwood Step should not include the full block's label.
-- Check stone and gem walls in straight runs, corners
+- Check wood and stone piece names in English and your usual game language.
+ The names should describe the material and shape without repeating “block of”.
+- Check stone walls in straight runs, corners
and junctions. The panels should be lower than the posts. Add and remove a
block or torch above the middle of a run, then check selection and collision.
-- Craft redwood, ebony, eucalyptus, bamboo, stones and gem blocks using the
- core's ordinary patterns. Check quantities and native slab recipes.
+- Craft the available wood and stone pieces using the core's ordinary patterns.
+ Check quantities, native slab recipes and combining corners into steps, steps
+ into slabs and slabs into full blocks. Check the recipe book too.
- Place and join slabs, vertical slabs, steps and corners in every direction.
- Check textures, native stairs, nonwood walls, breaking and item names.
- Use regular BOP slab items for vertical placement. Breaking or pick-block
should return the regular slab, with no vertical slab creative entry.
- Place blocks above and beside pieces; exposed internal faces must remain visible.
- Check that hellbark does not burn, while other wooden pieces behave normally.
+- On a 1.12 world copy, inspect retired woods, gems and thatching. They should
+ use the replacements listed in the world-upgrade guide, keeping their shapes.
+ Check old BOP terrain, plants and biome boundaries as well as building pieces.
- On separate world copies, compare replacement off and on, then save and reload.
- Inspect converted BOP builds and container contents, including counts and custom names.
- Check old cave formations, jelled poison pools and tree moss changed to willow
@@ -23,8 +28,8 @@ server. Do not put sources or Javadoc jars in the mods folder.
- Craft a Flower Basket and open it from each hand. Try manual storage and plant
pickup, including a nearly full basket. Rename it, store it in a chest and
reload. Check that the open basket cannot be moved, swapped or thrown.
-- On an old-world copy, check turnip seeds, two brown dyes per pinecone, and
- eight clover per dull flower band. Include custom names and a stack that needs
+- On an old-world copy, check beetroot seeds from turnip seeds, two brown dyes
+ per pinecone, and eight oxeye daisies per dull flower band. Include custom names and a stack that needs
a spare inventory slot, then save and reload.
No terrain is generated. Wooden walls and unrelated biome materials are not included.
diff --git a/docs/WORLD-UPGRADES.md b/docs/WORLD-UPGRADES.md
index 324b517d..bdf58e2c 100644
--- a/docs/WORLD-UPGRADES.md
+++ b/docs/WORLD-UPGRADES.md
@@ -1,5 +1,212 @@
# Existing worlds
+## Moving to Minecraft 26.3
+
+Brimstone becomes orpiment, and thermal calcite becomes sphalerite. The
+matching brick slabs, stairs and walls keep their shapes and orientations.
+Stored items keep their counts, custom names and other data. Sphalerite no
+longer uses the old colour changes based on distance from a vent.
+
+Back up your world before updating the core and add-on together. Use Biomes
+O' Plenty 26.3.0.0.16, TerraBlender 26.3.0.0.2 and GlitchCore 26.3.0.0.0.
+
+## Moving to Minecraft 26.1.2
+
+Back up the entire world before upgrading. Update the core mod and this add-on
+together. Use Biomes O' Plenty 26.1.2.0.22, TerraBlender 26.1.2.0.3 and
+GlitchCore 26.1.2.0.2. Keep the older save and its data folders until you've
+checked your builds, inventories and Flower Baskets in the upgraded copy.
+
+## Moving to Minecraft 1.21.11
+
+Use Biomes O' Plenty 21.11.0.32, TerraBlender 21.11.0.0 and GlitchCore 21.11.0.4.
+Keep a backup of the whole world before opening it. Saved origin oak and rose
+plants use the new BOP names. Maple leaf piles become full patches of matching
+leaf litter, and rainbow birches use vanilla birch. Old Wildflowers use BOP's
+purple Wildflowers. BOP boats keep their wood type and any chest contents.
+
+Saved Aspen Glade, Grassland and Jacaranda Glade biome entries now use Forested
+Field, Field and Lavender Field. Existing terrain and builds stay in place.
+New chunks generate with the installed BOP version.
+
+## Moving to Minecraft 1.21.1
+
+Use Biomes O' Plenty 21.1.0.14, TerraBlender 4.1.0.8 and GlitchCore 2.1.0.2.
+The BOP blocks, items and biomes from the supported 1.20.6 build have the
+same IDs in this version.
+Keep a backup of the whole world, including its data folder. Flower Baskets
+can read their older inventory format and use Minecraft's new item data when
+you change what is inside. Existing pieces keep their material and shape.
+
+## Moving to Minecraft 1.20.1
+
+Old BOP cherry wood uses Minecraft's cherry blocks. Sky's cherry pieces keep
+their saved IDs and shapes and use the vanilla cherry texture. Standing and
+wall signs keep their orientation, text, dye and glow.
+
+White cherry plants use Snowblossom, and the old autumn leaves and saplings
+use the matching maple colours. Overgrown black sand becomes mossy black sand.
+Rose quartz shards become rose quartz chunks. Retired biomes use nearby
+equivalents, such as Cherry Grove, Moor and Lavender Field. Saved terrain
+stays in place.
+
+Use Biomes O' Plenty 19.0.0.96 with TerraBlender and GlitchCore. Keep the
+world's data folder with your backup, since older chunks may still need its
+registry snapshots when you first visit them.
+
+## Moving to Minecraft 1.19.4
+
+BOP's mud, mud bricks, mud-brick slabs, stairs and walls use the matching
+vanilla blocks. Slab halves, stair directions, wall connections and waterlogging
+are retained. Existing Sky pieces keep their IDs, materials and orientations.
+Mud balls become clay balls and individual mud bricks become ordinary bricks;
+stored stacks keep their counts and custom data.
+
+Rooted sand becomes rooted dirt. It stays a solid, non-falling block, although
+its appearance and plant support follow rooted dirt. Rainbow Hills becomes
+Auroral Garden, Boreal Forest becomes Coniferous Forest, Wooded Scrubland becomes
+Scrubland and Wooded Wasteland becomes Wasteland. Existing terrain is not rebuilt.
+Chunks opened later receive the same replacements.
+
+Keep the registry sidecars with the world and its backups. Test a copy of the
+whole modpack before upgrading; this add-on cannot recover unrelated missing mods.
+
+## Moving to Minecraft 1.18.2
+
+The pieces and Flower Basket retain their saved identities and contents. BOP
+now uses TerraBlender; install it alongside BOP on both the client and server.
+
+Removed tree roots become weeping vines, keeping their growth age and hanging
+stems. Mangrove roots become small dripleaves, keeping both halves and their
+water. These use the replacement plants' appearance and behaviour.
+The older single-block root from 1.12 stays one block tall as BOP dead grass.
+
+Several biome variants were removed or renamed. Saved biome identities use
+similar surviving biomes: Meadow becomes Field, Grove becomes Mediterranean
+Forest, Highland Crag becomes Crag, and the alpine pair uses Frozen Peaks and
+Snowy Slopes. Existing terrain stays in place. Biome names are retained through
+Minecraft's conversion to section palettes, including chunks opened later.
+Keep both registry sidecars with the world and its backups.
+
+## Moving to Minecraft 1.17.1
+
+BOP removed nether crystals and nether crystal blocks. They become rose quartz
+clusters and rose quartz blocks. Placed clusters keep their floor, ceiling or
+wall attachment; stored items keep their counts, names and custom data. The
+original crystals were dry, so their replacements start dry too.
+
+Existing building pieces keep their material palettes and orientations. Old
+palette holders can become equivalent native BOP pieces when loaded. Flower
+Baskets retain their slots and contents. Earlier plant and biome replacements
+continue to use the matching current names; terrain is not regenerated.
+
+This covers supported blocks and items. It does not recover old BOP boat
+entities. Check the whole modpack on a backup copy before upgrading.
+
+## Moving to Minecraft 1.16.5
+
+The piece catalogue keeps its existing materials, IDs and orientations. BOP
+renamed tall wheat to barley and tall cattails to cattails. Both halves of
+those plants are retained. Older one-block cattails and roots use BOP dead
+grass, so they remain one block tall. Full ash blocks use basalt. Stored
+items keep their counts and custom data. Old white sandstone and mud-brick walls
+keep their connections, posts and waterlogging through the changed wall format.
+
+Removed biomes use similar surviving biomes in the same dimension: for example,
+Ghost Forest becomes Muskeg, Mangrove becomes Bayou Mangrove, Maple Woods
+becomes Seasonal Forest and Origin Hills becomes Origin Valley. Undergarden
+uses Undergrowth, while Ashen Inferno uses Basalt Deltas. Existing terrain is
+not regenerated. Earlier replacements are carried forward to live names.
+
+Keep the named-registry sidecar with the world so chunks opened later retain
+their original biome identities. Test a backup copy of the whole modpack before
+upgrading; this add-on cannot recover unrelated missing mods or their entities.
+
+## Moving from Minecraft 1.14.4 to 1.15.2
+
+BOP removed ethereal wood in this version. Saved planks, logs, stripped logs,
+doors, fences, gates, slabs, stairs, buttons and pressure plates use birch.
+Leaves and saplings use white cherry, including potted saplings. Ethereal boat
+items become birch boat items. This does not provide recovery for saved BOP
+boat entities.
+
+Dried sand becomes BOP's dried salt, and barley becomes tall wheat. Saved
+directions, slab halves, waterlogging, stack counts and custom item data are
+kept. These are replacements rather than the original textures. An invalid
+waterlogged double slab stops recovery with an explanation instead of losing
+its water.
+
+Four removed biomes have replacements in the same dimension: Dead Reef uses
+End Midlands, Ethereal Forest uses End Highlands, Fungi Forest uses Undergarden
+and Glowstone Grotto uses the vanilla Nether. Existing terrain stays in place.
+
+Keep `data/skysbuildingpiecesbop_named_registry.dat` with the world. It preserves
+the old biome registry so chunks opened after the first upgrade save still
+receive the right replacement. It belongs in world backups alongside the core's
+recovery data. Already upgraded chunks are not converted again.
+
+## Moving from Minecraft 1.12.2 to 1.15.2
+
+BOP changed both its block names and its material list. The add-on converts
+supported old material palettes before Minecraft reads them, keeping slab
+halves, stair directions, gate states, stack counts and custom item data.
+Existing migration totals are retained. Chunks containing recovered pieces or
+inventories are preserved even if they had not finished population in the old
+world; this is not terrain retrogen.
+
+Some original materials no longer exist. Sacred oak and eucalyptus use oak;
+mangrove uses birch, ebony uses dark oak, pine uses jungle wood and bamboo
+thatching uses willow. Removed gem pieces use similarly coloured terracotta:
+amber and topaz become orange, amethyst magenta, malachite cyan, peridot lime,
+ruby red, sapphire light blue and tanzanite purple. These are replacements,
+not exact matches for the old textures. The original saved piece identities
+remain recognised when loading unopened chunks later.
+
+Flower Basket contents use current plant names. White anemones become oxeye
+daisies, tiny cacti become regular cacti and the old small water lilies become
+vanilla lily pads. Flowering saplings use BOP's flowering oak. Missing or
+malformed contents still stop recovery rather than disappearing.
+
+Native BOP logs, leaves, soil, farmland, doors and other supported blocks also
+need their old metadata split into named states. Log axes, placed leaves,
+door halves, moisture and saved fluid levels are carried across. The add-on
+does this while reading old chunks, including chunks opened after the first
+upgrade save.
+
+BOP removed some of those blocks altogether. Loamy, sandy and silty soils use
+vanilla dirt, grass, paths and farmland, keeping coarse dirt separate. Retired
+woods use the same replacements listed above for their logs, leaves and doors.
+Old coral uses similar vanilla coral or seagrass; seaweed becomes kelp. Quicksand
+becomes sand. Removed blood, poison, honey and hot-spring liquids become water,
+so their special effects do not carry over.
+
+Retired gem ores use similar vanilla ores: redstone for ruby, emerald for
+peridot and malachite, lapis for amethyst, tanzanite and sapphire, and gold for
+amber and topaz. Stored gems become redstone, emeralds, lapis lazuli or gold
+nuggets respectively, one for one. The coloured full gem blocks and building
+pieces still use the terracotta replacements listed above.
+
+Old terraria become small potted plants. Crystal becomes purpur, honey blocks
+become slime blocks, hive blocks become yellow terracotta and blue fire becomes
+ordinary fire. These replacements keep occupied positions; they do not recreate
+the removed features. Removed fruits become apples, berries become sweet
+berries, empty jars become glass bottles and dyes use their vanilla equivalents.
+Mud balls keep BOP's current mud-ball item. Stack counts and unrelated custom
+data are retained.
+
+Saved BOP biome numbers are updated too. Biomes that still exist keep their
+names. Removed biomes use similar current ones: for example, Lavender Fields
+becomes Lavender Field, Tropical Island becomes Tropics, and the old snowy
+tundra uses vanilla snowy tundra so it stays cold enough for snow. This updates
+the saved biome without generating its terrain again. Unrelated mods' biomes
+still need their own upgrade support.
+
+These mappings cover the add-on's materials and supported recovered contents.
+They are not a promise that every old BOP biome, entity or unrelated block has
+an upgrade path. Test the whole pack on a copy before moving your main world.
+
+## Earlier pieces and decorations
+
Back up the world and test a separate copy. Remove BuildingBricks only after
every saved material, shape and item has a proven recovery path. Unsupported
content still needs a replacement, even if the buildings look correct.
@@ -66,20 +273,18 @@ not depend on the BuildingBricks replacement setting.
| Old item | Replacement |
|---|---|
-| One BOP turnip seed | One Pam's HarvestCraft turnip seed |
-| One pinecone | Two BOP brown dyes |
-| One dull flower band | Eight BOP clover |
-
-Pinecones retain their original dye yield. The flower band returns its original
-crafting ingredients rather than pretending that clover is wearable headgear.
-Names and unrelated custom data are kept. The old item variant is not copied to
-the replacement, which would turn clover into another flower. Old capabilities
+| One BOP turnip seed | One vanilla beetroot seed |
+| One pinecone | Two vanilla brown dyes |
+| One dull flower band | Eight vanilla oxeye daisies |
+
+Pinecones retain their original dye yield. BOP no longer has the old clover, so
+the flower band becomes eight similar small white flowers. Names and unrelated
+custom data are kept. The old item variant is not copied to the replacement.
+Old capabilities
that a replacement cannot use retain an archive in the item's custom data.
-HarvestCraft is not required for the building pieces or the other recoveries.
-It must provide `harvestcraft:turnipseeditem` if the world contains old BOP turnip
-seeds. Missing replacements stop the upgrade with an explanation rather than
-substituting an unrelated seed or deleting the stack.
+These target-version substitutions do not require HarvestCraft. Missing
+replacements stop the upgrade with an explanation rather than deleting stacks.
Expanded stacks use spare slots in recognised chests, player and ender
inventories, and Flower Baskets. If there is not enough room, or the inventory
@@ -98,10 +303,8 @@ The add-on uses 74 block IDs: 66 piece palettes, one retired-rock palette,
two replacement decorations and five hidden recovery holders. The holders
are not creative entries or playable blocks.
-Together with the core's 236 IDs, that is 310 additional IDs. Existing native
+Together with the core's 238 registered blocks, that is 312 additional blocks.
+The optional Mineralogy catalogue adds another 35. Existing native
BOP blocks are not counted again. New pieces use fixed metadata palettes
rather than material tile entities; saved material and orientation assignments
stay unchanged. The Flower Basket adds no block IDs.
-
-Core 0.3.7 adds two path-piece IDs, making that combination 312 IDs instead.
-The add-on's own catalogue remains at 74 IDs.
diff --git a/gradle.properties b/gradle.properties
index 0ea8875a..056b143b 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -4,23 +4,28 @@ org.gradle.configuration-cache=false
org.gradle.caching=true
org.gradle.parallel=false
net.minecraftforge.gradle.merge-source-sets=false
-minecraft_version=1.12.2
-minecraft_version_range=[1.12.2]
-forge_version=14.23.5.2859
-mapping_channel=stable
-mapping_version=39-1.12
+minecraft_version=26.3
+minecraft_version_range=[26.3,26.4,)
+forge_version=66.0.2
+mapping_channel=official
+mapping_version=26.3
loader_name=forge
loader_code=1
-java_version=8
-java_toolchain_version=8.0.502+7
-gradle_java_version=17
+java_version=25
+java_toolchain_version=25.0.3+9
+java_setup_version=25.0.3+9.0.LTS
+gradle_java_version=25
mod_id=skysbuildingpiecesbop
release_repository=MinecraftModDevelopmentMods/SkysBuildingPieces-BiomesOPlenty-Addon
release_enabled=true
curseforge_project_id=1755976
mod_name=Sky's Building Pieces - Biomes O Plenty
mod_license=LGPL-2.1-only
-mod_version=0.2.4.112021
+mod_version=0.2.5.2603001
mod_group=zone.moddev.mc.skysbuildingpiecesbop
mod_authors=SkyBlade1978
mod_description=Biomes O Plenty building pieces with ordinary crafting recipes and optional legacy recovery. No world generation.
+forge_version_range=[66,)
+loader_version_range=[66,)
+mcp_version=20260918.230105
+gradle_java_toolchain_version=25.0.3+9
diff --git a/gradle/catalogue-checksums.properties b/gradle/catalogue-checksums.properties
index 555bbdcf..593c7eef 100644
--- a/gradle/catalogue-checksums.properties
+++ b/gradle/catalogue-checksums.properties
@@ -1,2 +1,2 @@
-materials=E0D56E750372E149077CEC920BF6EF7CA4703BFD4DB84F319769130907A46282
+materials=06ABD21E6A1722DED3306CECECCDB996D58D7B523C874CBD4972C4A2088AF53F
palettes=804175ED9CAF2960F23A907628646A8790314637D1604CCE78FD23F8D21FECB0
diff --git a/gradle/core-dependency.properties b/gradle/core-dependency.properties
index 28e0ee0b..a9698b6c 100644
--- a/gradle/core-dependency.properties
+++ b/gradle/core-dependency.properties
@@ -1,6 +1,13 @@
repository=MinecraftModDevelopmentMods/SkysBuildingPieces
-commit=af3ce9b73d215a2f9e27a11324da56a752e4edac
-version=0.3.8.112021
+kind=local-candidate
+commit=c4df80b09d366b293cd3d06b5b92b376b34fafe3
+version=0.3.9.2603001
api=1
-bop_file=3558882
-bop_sha256=1C53D3EE39F557162591F618C58B79DC6861D24522EF785794EE4A815F8A3908
+sha256=A21755D614D3953D08CE1545153800DA1722A63EEC330BC868C8C1A7DF76D298
+development.sha256=A21755D614D3953D08CE1545153800DA1722A63EEC330BC868C8C1A7DF76D298
+bop_modrinth_version=rGqDYMqM
+bop_sha256=528FC66C9D70958B9E0476160F9C0F6D833DD297D518A4EF511AC08B10F40A49
+terrablender_modrinth_version=XxuGDwFD
+terrablender_sha256=39733452924944AA5DA5F8E033DCB2D4A435D141FAF356934D0901D8C42B6E19
+glitchcore_modrinth_version=dcJyf63B
+glitchcore_sha256=2D4B526B38DE75284D16F91A883D5FF6DD519627599623483C3EF711E9160D45
diff --git a/gradle/native-bootstrap.gradle b/gradle/native-bootstrap.gradle
new file mode 100644
index 00000000..a660a059
--- /dev/null
+++ b/gradle/native-bootstrap.gradle
@@ -0,0 +1,412 @@
+// Target-native startup checks remain separate from ordinary game launches.
+
+def auditServerLogs = { File directory, String phase ->
+ def audit = providers.exec {
+ commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'),
+ file('tools/native_client_log.py'), '--server', '--directory', directory,
+ '--phase', phase
+ ignoreExitValue = true
+ }
+ logger.lifecycle(audit.standardOutput.asText.get().trim())
+ String failure = audit.standardError.asText.get().trim()
+ if (!failure.isEmpty()) logger.lifecycle(failure)
+ if (audit.result.get().exitValue != 0) {
+ throw new GradleException('The complete native server log audit failed; see its diagnostic above')
+ }
+}
+ext.auditNativeBopServerLogs = auditServerLogs
+
+def bootstrapClasses = layout.buildDirectory.dir('native-bootstrap/classes')
+def compileBootstrap = tasks.register('compileNativeBootstrap', JavaCompile) {
+ dependsOn tasks.named('classes')
+ source fileTree('src/bootstrapTest/java')
+ source file('src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketChecks.java')
+ source file('src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemChecks.java')
+ source file(new File(coreDirectory, 'src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/RecipeChecks.java'))
+ source file(new File(coreDirectory, 'src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/RecipeBookChecks.java'))
+ source file(new File(coreDirectory, 'src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/RecombinationChecks.java'))
+ source file(new File(coreDirectory, 'src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/PersistenceRegistrationChecks.java'))
+ ['NativeRecipeProbe', 'NativeLegacyChecks', 'DirtCoverageChecks', 'CoverageContributor', 'NativeProbeItems'].each { name ->
+ source file(new File(coreDirectory, 'src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/' + name + '.java'))
+ }
+ source fileTree(new File(coreDirectory, 'src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/legacy'))
+ ['ClientProbe', 'ClientStatus', 'ConfigScreenChecks', 'ProbeProxy', 'RecipePreviewModels', 'PresentationChecks', 'GlassModelProbe'].each { name ->
+ source file(new File(coreDirectory, 'src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/' + name + '.java'))
+ }
+ ['BasketGuiClientChecks', 'BasketDisconnectClientChecks', 'BasketClientChecks', 'BopProbeItems'].each { name ->
+ source file('src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/' + name + '.java')
+ }
+ classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath, sourceSets.main.runtimeClasspath)
+ destinationDirectory = bootstrapClasses
+}
+def bootstrapJar = tasks.register('nativeBootstrapJar', Jar) {
+ dependsOn compileBootstrap
+ archiveFileName = 'building-pieces-bop-native-checks.jar'
+ destinationDirectory = layout.buildDirectory.dir('native-bootstrap')
+ from bootstrapClasses
+ from 'src/bootstrapTest/resources'
+ from('src/testFixtures/bop-1.14.4-native-catalogue.json') { into 'fixtures' }
+ from('src/testFixtures/bop-1.15.2-native-catalogue.json') { into 'fixtures' }
+ from('src/testFixtures/bop-1.16.5-native-catalogue.json') { into 'fixtures' }
+ from('src/testFixtures/bop-1.17.1-native-catalogue.json') { into 'fixtures' }
+ from('src/testFixtures/bop-1.18.2-native-catalogue.json') { into 'fixtures' }
+ from('src/testFixtures/biomes') { into 'fixtures/biomes' }
+}
+tasks.register('preparePackagedNativeProbe') { dependsOn bootstrapJar }
+
+tasks.register('verifyNativeServerLogPolicy', Exec) {
+ group = 'verification'
+ commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'),
+ 'tools/test_native_server_log.py'
+}
+tasks.named('check') { dependsOn tasks.named('verifyNativeServerLogPolicy') }
+
+tasks.register('verifyNativeClientLogPolicy', Exec) {
+ group = 'verification'
+ commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'),
+ 'tools/test_native_client_log.py'
+}
+tasks.named('check') { dependsOn tasks.named('verifyNativeClientLogPolicy') }
+// The combined verification profile uses exact optional jars, never production dependencies.
+def nativeCompanions = providers.gradleProperty('bopNativeCompanions').getOrElse('false').toBoolean()
+def companionLock = new Properties()
+file('gradle/optional-runtime.properties').withInputStream { companionLock.load(it) }
+def companionJars = ['grass', 'mineralogy', 'orespawn'].collect { name ->
+ file(companionLock.getProperty(name + '.development.path'))
+}
+def verifyCompanions = tasks.register('verifyBopNativeCompanions') {
+ doLast {
+ ['grass', 'mineralogy', 'orespawn'].each { name ->
+ ['', '.development'].each { kind ->
+ File artifact = file(companionLock.getProperty(name + kind + '.path'))
+ String expected = companionLock.getProperty(name + kind + '.sha256')
+ if (!artifact.isFile() || java.security.MessageDigest.getInstance('SHA-256')
+ .digest(artifact.bytes).encodeHex().toString().toUpperCase(Locale.ROOT) != expected) {
+ throw new GradleException('The optional native reference is missing or changed: ' + artifact.name)
+ }
+ }
+ }
+ }
+}
+
+def forwardFixture = providers.gradleProperty('bopForwardFixtureDir')
+def forwardArchive = providers.gradleProperty('bopForwardFixtureArchive')
+def hasForward = forwardFixture.isPresent() || forwardArchive.isPresent()
+def nativeRunName = providers.gradleProperty('bopNativeRunName').getOrElse(hasForward
+ ? 'native-forward-upgrade-run' : 'native-bootstrap-run')
+if (!(nativeRunName ==~ /native-[a-z0-9-]+/)) {
+ throw new GradleException('Native runtime folders need a simple name under build')
+}
+def directory = layout.buildDirectory.dir(nativeRunName)
+def prepare = tasks.register('prepareNativeBootstrap') {
+ dependsOn bootstrapJar
+ if (nativeCompanions) dependsOn verifyCompanions
+ dependsOn project.ext.bopDevelopmentTask
+ doLast {
+ File target = directory.get().asFile
+ String expectedName = nativeRunName
+ if (target.canonicalFile != new File(buildDir, expectedName).canonicalFile) {
+ throw new GradleException('Native checks must remain inside their build directory')
+ }
+ if (target.exists()) throw new GradleException('Preserve existing runtime evidence before another run: ' + target)
+ if (forwardArchive.isPresent()) {
+ if (forwardFixture.isPresent()) throw new GradleException('Choose one forward source')
+ File archive = file(forwardArchive.get())
+ File receipt = file(archive.path.replaceFirst(/\.zip$/, '.json'))
+ def metadata = new groovy.json.JsonSlurper().parse(receipt)
+ String checksum = java.security.MessageDigest.getInstance('SHA-256')
+ .digest(archive.bytes).encodeHex().toString().toUpperCase()
+ if (checksum != metadata.archive_sha256) throw new GradleException('The sealed source fixture changed')
+ copy { from zipTree(archive); into new File(target, 'world') }
+ copy { from receipt; into target; rename { 'forward-fixture.json' } }
+ File doors = file(archive.path.replaceFirst(/\.zip$/, '.neighbours.json'))
+ if (doors.isFile()) {
+ copy { from doors; into target; rename { 'forward-neighbours.json' } }
+ }
+ File blockRenames = file(archive.path.replaceFirst(/\.zip$/, '.forge50-grass-rename.json'))
+ if (blockRenames.isFile()) {
+ copy { from blockRenames; into target; rename { 'forward-block-renames.json' } }
+ }
+ File fluid = file(archive.path.replaceFirst(/\.zip$/, '.forge61-fluid.json'))
+ if (fluid.isFile()) {
+ copy { from fluid; into target; rename { 'forward-fluid-changes.json' } }
+ }
+ File biomes = file(archive.path.replaceFirst(/\.zip$/, '.biomes.json'))
+ if (biomes.isFile()) {
+ copy { from biomes; into target; rename { 'forward-biomes.json' } }
+ }
+ }
+ if (forwardFixture.isPresent()) {
+ File source = file(forwardFixture.get()).canonicalFile
+ if (!new File(source, 'level.dat').isFile() || source.toPath().startsWith(target.toPath())) {
+ throw new GradleException('Forward checks need a separate sealed source world')
+ }
+ copy { from source; into new File(target, 'world') }
+ File manifest = new File(source.parentFile, 'source-fixture.json')
+ if (!manifest.isFile()) throw new GradleException('Forward checks need the genuine source manifest')
+ copy { from manifest; into target; rename { 'forward-fixture.json' } }
+ }
+ copy {
+ from bootstrapJar.flatMap { it.archiveFile }
+ from project.ext.bopDevelopmentJar
+ from project.ext.terraBlenderDevelopmentJar, project.ext.glitchCoreDevelopmentJar
+ from coreDevelopmentJar
+ into new File(target, 'mods')
+ }
+ if (nativeCompanions) {
+ copy { from companionJars; into new File(target, 'mods') }
+ }
+ copy { from new File(coreDirectory, 'src/bootstrapTest/defaultconfigs'); into target }
+ if (hasForward) {
+ copy { from new File(target, 'world/level.dat'); into target; rename { 'source-level.dat' } }
+ }
+ new File(target, 'eula.txt').text = 'eula=true\n'
+ ['banned-players.json', 'banned-ips.json', 'ops.json', 'whitelist.json'].each {
+ new File(target, it).text = '[]\n'
+ }
+ new File(target, 'server.properties').text = '''level-name=world
+level-seed=4815162342
+server-ip=127.0.0.1
+server-port=0
+online-mode=false
+generate-structures=false
+max-tick-time=120000
+'''
+ }
+}
+// This short gate records the actual target registry before upgrade assertions run.
+tasks.register('nativeRegistryTest', JavaExec) {
+ dependsOn prepare
+ workingDir directory
+ project.ext.configureRecipeBookRun(delegate, 'runServer')
+ systemProperty 'skysbuildingpieces.bopRegistryOnly', 'true'
+ doLast {
+ auditServerLogs(directory.get().asFile, 'registry')
+ String log = new File(directory.get().asFile, 'logs/latest.log').getText('UTF-8')
+ if (!log.contains('BUILDING_PIECES_BOP_REGISTRY_ONLY_PASS') || !log.contains('Done (')
+ || !fileTree(new File(directory.get().asFile, 'crash-reports')).isEmpty()) {
+ throw new GradleException('Target BOP registry startup did not complete cleanly')
+ }
+ }
+}
+
+tasks.register('nativeBiomeUpgradeTest', JavaExec) {
+ dependsOn prepare
+ workingDir directory
+ project.ext.configureRecipeBookRun(delegate, 'runServer')
+ systemProperty 'skysbuildingpieces.bopBiomeFixerOnly', 'true'
+ doLast {
+ auditServerLogs(directory.get().asFile, 'biome-fixer')
+ String log = new File(directory.get().asFile, 'logs/latest.log').getText('UTF-8')
+ if (!log.contains('BOP_NATIVE_NAMED_BIOME_FIXER_PASS') || !log.contains('Done (')
+ || !fileTree(new File(directory.get().asFile, 'crash-reports')).isEmpty()) {
+ throw new GradleException('Native BOP biome fixing did not complete cleanly')
+ }
+ }
+}
+
+tasks.register('nativeBootstrapTest', JavaExec) {
+ dependsOn prepare
+ workingDir directory
+ project.ext.configureRecipeBookRun(delegate, 'runServer')
+ systemProperty 'skysbuildingpieces.bopDelayedBiomes',
+ providers.gradleProperty('bopDelayBiomes').getOrElse('false')
+ doLast {
+ File target = directory.get().asFile
+ auditServerLogs(target, hasForward ? 'forward' : 'bootstrap')
+ String log = new File(target, 'logs/latest.log').getText('UTF-8')
+ if (!log.contains('BUILDING_PIECES_BOP_NATIVE_BOOTSTRAP_PASS') ||
+ hasForward && !log.contains('BUILDING_PIECES_BOP_FORWARD_UPGRADE_PASS') ||
+ !log.contains('Done (') ||
+ !fileTree(new File(target, 'crash-reports')).isEmpty()) {
+ throw new GradleException('Native BOP startup did not complete cleanly')
+ }
+ }
+}
+
+tasks.register('nativeForwardReloadTest', JavaExec) {
+ dependsOn bootstrapJar
+ workingDir directory
+ project.ext.configureRecipeBookRun(delegate, 'runServer')
+ systemProperty 'skysbuildingpieces.bopDelayedBiomes',
+ providers.gradleProperty('bopDelayBiomes').getOrElse('false')
+ doFirst {
+ File target = workingDir
+ if (!new File(target, 'forward-fixture.json').isFile()
+ || !new File(target, 'world/level.dat').isFile()) {
+ throw new GradleException('Run the copied forward fixture once before its reload check')
+ }
+ [bootstrapJar.get().archiveFile.get().asFile, coreDevelopmentJar, project.ext.bopDevelopmentJar, project.ext.terraBlenderDevelopmentJar, project.ext.glitchCoreDevelopmentJar].each { input ->
+ File original = file(input)
+ File installed = new File(target, 'mods/' + original.name)
+ if (!installed.isFile() || !java.util.Arrays.equals(original.bytes, installed.bytes)) {
+ throw new GradleException('The forward candidate changed before reload: ' + original.name)
+ }
+ }
+ }
+ doLast {
+ auditServerLogs(workingDir, 'forward-reload')
+ String log = new File(workingDir, 'logs/latest.log').getText('UTF-8')
+ if (!log.contains('BUILDING_PIECES_BOP_FORWARD_UPGRADE_PASS') || !log.contains('Done (')
+ || !fileTree(new File(workingDir, 'crash-reports')).isEmpty()) {
+ throw new GradleException('The forward fixture did not reload cleanly')
+ }
+ }
+}
+
+def clientPhase = providers.gradleProperty('bopClientPhase').orElse('client-basket')
+def clientDisconnect = clientPhase.get().startsWith('client-basket-disconnect')
+def clientReload = providers.gradleProperty('bopClientReload').getOrElse('false').toBoolean()
+if (clientReload && clientDisconnect) throw new GradleException('Use the dedicated disconnect reload phase')
+if (!(clientPhase.get() in ['client-basket', 'client-basket-disconnect', 'client-basket-disconnect-reload'])) {
+ throw new GradleException('Unknown native basket client phase: ' + clientPhase.get())
+}
+def clientRunName = providers.gradleProperty('bopNativeClientRunName').getOrElse(clientDisconnect
+ ? 'native-client-disconnect-run' : 'native-client-test-run')
+if (!(clientRunName ==~ /native-[a-z0-9-]+/)) {
+ throw new GradleException('Native client folders need a simple name under build')
+}
+def clientDirectory = layout.buildDirectory.dir(clientRunName)
+def prepareClient = tasks.register('prepareNativeClientTest') {
+ dependsOn bootstrapJar, project.ext.bopDevelopmentTask
+ if (nativeCompanions) dependsOn verifyCompanions
+ doLast {
+ File target = clientDirectory.get().asFile
+ if (target.canonicalFile != new File(buildDir, clientRunName).canonicalFile) {
+ throw new GradleException('Client checks must stay inside their build directory')
+ }
+ if (clientReload || clientPhase.get().endsWith('-reload')) {
+ File completed = new File(target, clientReload
+ ? 'saves/building-pieces-world/level.dat' : 'basket-disconnect-pass.txt')
+ if (!completed.isFile()) {
+ throw new GradleException('Complete the open-window disconnect before checking its saved player')
+ }
+ List reloadInputs = [bootstrapJar.get().archiveFile.get().asFile, coreDevelopmentJar,
+ project.ext.bopDevelopmentJar, project.ext.terraBlenderDevelopmentJar, project.ext.glitchCoreDevelopmentJar]
+ if (nativeCompanions) reloadInputs.addAll(companionJars)
+ reloadInputs.each { input ->
+ File original = file(input)
+ File installed = new File(target, 'mods/' + original.name)
+ if (!installed.isFile() || !java.util.Arrays.equals(original.bytes, installed.bytes)) {
+ throw new GradleException('The disconnect candidate changed before reload: ' + original.name)
+ }
+ }
+ return
+ }
+ if (target.exists()) throw new GradleException('Preserve existing runtime evidence before another run: ' + target)
+ copy {
+ from bootstrapJar.flatMap { it.archiveFile }
+ from project.ext.bopDevelopmentJar
+ from project.ext.terraBlenderDevelopmentJar, project.ext.glitchCoreDevelopmentJar
+ from coreDevelopmentJar
+ into new File(target, 'mods')
+ }
+ if (nativeCompanions) {
+ copy { from companionJars; into new File(target, 'mods') }
+ }
+ copy { from new File(coreDirectory, 'src/bootstrapTest/defaultconfigs'); into target }
+ ['banned-players.json', 'banned-ips.json', 'ops.json', 'whitelist.json'].each {
+ new File(target, it).text = '[]\n'
+ }
+ new File(target, 'options.txt').text = 'lang:en_us\nfullscreen:false\nrenderDistance:2\n'
+ }
+}
+tasks.register('nativeClientTest', JavaExec) {
+ dependsOn prepareClient
+ workingDir clientDirectory
+ systemProperty 'skysbuildingpieces.integrationPhase', clientPhase.get()
+ project.ext.configureRecipeBookRun(delegate, 'runClient')
+ doLast {
+ logger.lifecycle(providers.exec {
+ commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'),
+ file('tools/native_client_log.py'), '--directory', clientDirectory.get().asFile,
+ '--phase', clientPhase.get() + (clientReload ? '-reload' : '')
+ }.standardOutput.asText.get().trim())
+ String log = new File(clientDirectory.get().asFile, 'logs/latest.log').getText('UTF-8')
+ String marker = clientPhase.get() == 'client-basket' ? 'BOP_BASKET_GUI_NETWORK_PASS'
+ : clientPhase.get().endsWith('-reload') ? 'BOP_BASKET_DISCONNECT_RELOAD_PASS'
+ : 'BOP_BASKET_DISCONNECT_PASS'
+ if (!log.contains(marker) || !log.contains('BOP_BASKET_CLIENT_PASS')) {
+ throw new GradleException('Basket client checks did not finish')
+ }
+ }
+}
+
+// Use a labelled test fixture to check installed-mod policy without bundling
+// the abandoned mod or adding it to ordinary development launches.
+def policyClasses = layout.buildDirectory.dir('legacy-policy-fixture/classes')
+def compilePolicy = tasks.register('compileBopLegacyPolicyFixture', JavaCompile) {
+ source fileTree(new File(coreDirectory, 'src/compatibilityTest/java'))
+ classpath = files(sourceSets.main.output, sourceSets.main.runtimeClasspath)
+ destinationDirectory = policyClasses
+}
+def policyJar = tasks.register('bopLegacyPolicyFixtureJar', Jar) {
+ dependsOn compilePolicy
+ archiveFileName = 'legacy-piece-policy-fixture.jar'
+ destinationDirectory = layout.buildDirectory.dir('legacy-policy-fixture')
+ from policyClasses
+ from 'src/compatibilityTest/resources'
+}
+tasks.register('preparePackagedBopPolicyFixture') { dependsOn policyJar }
+
+def policyRunName = providers.gradleProperty('bopPolicyRunName').getOrElse('legacy-policy-test-run-' + UUID.randomUUID())
+if (!(policyRunName ==~ /[a-z0-9-]+/)) {
+ throw new GradleException('Use a simple name for the replacement policy run directory.')
+}
+def policyDirectory = layout.buildDirectory.dir(policyRunName)
+def preparePolicy = tasks.register('prepareBopInstalledPolicyTest') {
+ dependsOn policyJar, bootstrapJar, project.ext.bopDevelopmentTask
+ doLast {
+ File target = policyDirectory.get().asFile
+ if (target.canonicalFile != new File(buildDir, policyRunName).canonicalFile) {
+ throw new GradleException('Policy checks must remain inside their build directory')
+ }
+ if (target.exists()) {
+ throw new GradleException('Preserve the existing policy evidence before another run: ' + target)
+ }
+ copy { from 'src/bootstrapTest/defaultconfigs'; into target }
+ copy {
+ from policyJar.flatMap { it.archiveFile }, bootstrapJar.flatMap { it.archiveFile }
+ from project.ext.bopDevelopmentJar
+ from project.ext.terraBlenderDevelopmentJar, project.ext.glitchCoreDevelopmentJar, coreDevelopmentJar
+ into new File(target, 'mods')
+ }
+ new File(target, 'eula.txt').text = 'eula=true\n'
+ ['banned-players.json', 'banned-ips.json', 'ops.json', 'whitelist.json'].each {
+ new File(target, it).text = '[]\n'
+ }
+ new File(target, 'server.properties').text = '''level-name=world
+level-seed=4815162342
+online-mode=false
+server-ip=127.0.0.1
+server-port=0
+generate-structures=false
+max-tick-time=120000
+'''
+ }
+}
+tasks.register('nativeInstalledBopPolicyTest', JavaExec) {
+ dependsOn preparePolicy
+ workingDir policyDirectory
+ systemProperty 'skysbuildingpieces.bopInstalledPolicy', 'true'
+ project.ext.configureRecipeBookRun(delegate, 'runServer')
+ doLast {
+ File target = policyDirectory.get().asFile
+ auditServerLogs(target, 'installed-policy')
+ String text = new File(target, 'logs/latest.log').getText('UTF-8')
+ if (!text.contains('BOP_INSTALLED_POLICY_PASS materials=33 installed_fixture=true')
+ || !text.contains('BUILDING_PIECES_BOP_NATIVE_BOOTSTRAP_PASS')
+ || !text.contains('Done (') || !fileTree(new File(target, 'crash-reports')).isEmpty()) {
+ throw new GradleException('Installed-mod BOP replacement checks did not finish')
+ }
+ }
+}
+
+tasks.named('check') { dependsOn tasks.named('nativeInstalledBopPolicyTest') }
+
+tasks.register('verifyPackagedClientNatives', Exec) {
+ group = 'verification'
+ commandLine providers.gradleProperty('pythonExecutable').getOrElse('python'),
+ 'tools/test_packaged_client_natives.py'
+}
+tasks.named('check') { dependsOn tasks.named('verifyPackagedClientNatives') }
diff --git a/gradle/native-runtime-log.gradle b/gradle/native-runtime-log.gradle
new file mode 100644
index 00000000..d6bdf33b
--- /dev/null
+++ b/gradle/native-runtime-log.gradle
@@ -0,0 +1,14 @@
+// The empty Forge 66 server reproduces this Windows report without any mods.
+// Keep it out of the build-only audit, and leave the raw logs in place.
+ext.readForgeRuntimeLog = { File log ->
+ if (project.forge_version != '66.0.2' ||
+ !System.getProperty('os.name').startsWith('Windows')) {
+ return log.getText('UTF-8')
+ }
+
+ def python = providers.gradleProperty('pythonExecutable').getOrElse('python')
+ return providers.exec {
+ commandLine python, file('tools/native_windows_log.py'), log
+ environment 'PYTHONIOENCODING', 'utf-8'
+ }.standardOutput.asText.get()
+}
diff --git a/gradle/optional-runtime.properties b/gradle/optional-runtime.properties
new file mode 100644
index 00000000..424c382e
--- /dev/null
+++ b/gradle/optional-runtime.properties
@@ -0,0 +1,13 @@
+# Optional Forge 66 jars for family checks.
+grass.path=../../SkysGrassSlabs263/SkysGrassSlabs/build/libs/SkysGrassSlabs-1.2.0.2603001.jar
+grass.sha256=20AE5DC12C97E8DC1D348282BE50697909E98BBCE8C3BD40E4CF910E7D85F7D9
+grass.development.path=../../SkysGrassSlabs263/SkysGrassSlabs/build/libs/SkysGrassSlabs-1.2.0.2603001.jar
+grass.development.sha256=20AE5DC12C97E8DC1D348282BE50697909E98BBCE8C3BD40E4CF910E7D85F7D9
+orespawn.path=../../../MinecraftMineralogy/OS263/OreSpawn/build/libs/OreSpawn-4.1.0.2603001.jar
+orespawn.sha256=A7FCDD626B9044091BFDB7277DA3169A5307EB5F97FCF88741A878401DCF1AEA
+orespawn.development.path=../../../MinecraftMineralogy/OS263/OreSpawn/build/libs/OreSpawn-4.1.0.2603001.jar
+orespawn.development.sha256=A7FCDD626B9044091BFDB7277DA3169A5307EB5F97FCF88741A878401DCF1AEA
+mineralogy.path=../../../MinecraftMineralogy/MinecraftMineralogy 263/MinecraftMineralogy/build/libs/Mineralogy-6.1.4.2603001.jar
+mineralogy.sha256=8C72389BC196F9732F32AFEBEF06E057D158DF4F4FA931059BE54480A230B453
+mineralogy.development.path=../../../MinecraftMineralogy/MinecraftMineralogy 263/MinecraftMineralogy/build/libs/Mineralogy-6.1.4.2603001.jar
+mineralogy.development.sha256=8C72389BC196F9732F32AFEBEF06E057D158DF4F4FA931059BE54480A230B453
diff --git a/gradle/recipe-book-verification.gradle b/gradle/recipe-book-verification.gradle
index 4be79cce..0a241c81 100644
--- a/gradle/recipe-book-verification.gradle
+++ b/gradle/recipe-book-verification.gradle
@@ -1,13 +1,17 @@
// Recipe-book checks get their own server runs rather than sharing one long gameplay tick.
-def bookDirectory = layout.buildDirectory.dir('recipe-book-test-run')
+def bookRunName = providers.gradleProperty('bopRecipeBookRunName').getOrElse('recipe-book-test-run-' + UUID.randomUUID())
+if (!(bookRunName ==~ /[a-z0-9-]+/)) {
+ throw new GradleException('Use a simple name for the recipe-book run directory.')
+}
+def bookDirectory = layout.buildDirectory.dir(bookRunName)
def prepareBook = tasks.register('prepareRecipeBookTest') {
- dependsOn tasks.named('integrationTestModJar')
+ dependsOn tasks.named('integrationTestModJar'), project.ext.bopDevelopmentTask
doLast {
File directory = bookDirectory.get().asFile
- if (directory.canonicalFile != new File(buildDir, 'recipe-book-test-run').canonicalFile) {
+ if (directory.canonicalFile != new File(buildDir, bookRunName).canonicalFile || directory.exists()) {
throw new GradleException('Recipe-book verification must stay inside the build directory')
}
- delete directory
+ copy { from 'src/bootstrapTest/defaultconfigs'; into directory }
copy {
from tasks.named('integrationTestModJar', Jar).flatMap { it.archiveFile }
into new File(directory, 'mods')
@@ -37,9 +41,9 @@ def bookTasks = [:]
dependsOn phase == 'fresh' ? prepareBook : bookTasks.fresh
workingDir bookDirectory
systemProperty 'skysbuildingpieces.integrationPhase', 'recipe-book-' + phase
- systemProperty 'log4j.configurationFile', project.ext.recipeBookLogConfiguration
project.ext.configureRecipeBookRun(delegate, 'runServer')
doLast {
+ project.ext.auditNativeBopServerLogs(bookDirectory.get().asFile, 'recipe-book-' + phase)
if (!new File(bookDirectory.get().asFile, 'world/recipe-book-' + phase + '.pass').isFile()) {
throw new GradleException('Recipe-book ' + phase + ' verification did not complete')
}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 0d4a9516..b1b8ef56 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 2c68b418..760c4596 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
-distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
+distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index cccdd3d5..249efbb0 100755
--- a/gradlew
+++ b/gradlew
@@ -1,78 +1,128 @@
-#!/usr/bin/env sh
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
-##
-## Gradle start up script for UN*X
-##
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
##############################################################################
# Attempt to set APP_HOME
+
# Resolve links: $0 may be a link
-PRG="$0"
-# Need this for relative symlinks.
-while [ -h "$PRG" ] ; do
- ls=`ls -ld "$PRG"`
- link=`expr "$ls" : '.*-> \(.*\)$'`
- if expr "$link" : '/.*' > /dev/null; then
- PRG="$link"
- else
- PRG=`dirname "$PRG"`"/$link"
- fi
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
done
-SAVED="`pwd`"
-cd "`dirname \"$PRG\"`/" >/dev/null
-APP_HOME="`pwd -P`"
-cd "$SAVED" >/dev/null
-
-APP_NAME="Gradle"
-APP_BASE_NAME=`basename "$0"`
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS=""
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD="maximum"
+MAX_FD=maximum
warn () {
echo "$*"
-}
+} >&2
die () {
echo
echo "$*"
echo
exit 1
-}
+} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
-case "`uname`" in
- CYGWIN* )
- cygwin=true
- ;;
- Darwin* )
- darwin=true
- ;;
- MINGW* )
- msys=true
- ;;
- NONSTOP* )
- nonstop=true
- ;;
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
- JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACMD=$JAVA_HOME/jre/sh/java
else
- JAVACMD="$JAVA_HOME/bin/java"
+ JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
- JAVACMD="java"
- which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
+ fi
fi
# Increase the maximum file descriptors if we can.
-if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
- MAX_FD_LIMIT=`ulimit -H -n`
- if [ $? -eq 0 ] ; then
- if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
- MAX_FD="$MAX_FD_LIMIT"
- fi
- ulimit -n $MAX_FD
- if [ $? -ne 0 ] ; then
- warn "Could not set maximum file descriptor limit: $MAX_FD"
- fi
- else
- warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
- fi
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
fi
-# For Darwin, add options to specify how the application appears in the dock
-if $darwin; then
- GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
-fi
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
-# For Cygwin, switch paths to Windows format before running java
-if $cygwin ; then
- APP_HOME=`cygpath --path --mixed "$APP_HOME"`
- CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
- JAVACMD=`cygpath --unix "$JAVACMD"`
-
- # We build the pattern for arguments to be converted via cygpath
- ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
- SEP=""
- for dir in $ROOTDIRSRAW ; do
- ROOTDIRS="$ROOTDIRS$SEP$dir"
- SEP="|"
- done
- OURCYGPATTERN="(^($ROOTDIRS))"
- # Add a user-defined pattern to the cygpath arguments
- if [ "$GRADLE_CYGPATTERN" != "" ] ; then
- OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
- fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
- i=0
- for arg in "$@" ; do
- CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
- CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
-
- if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
- eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
- else
- eval `echo args$i`="\"$arg\""
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
fi
- i=$((i+1))
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
done
- case $i in
- (0) set -- ;;
- (1) set -- "$args0" ;;
- (2) set -- "$args0" "$args1" ;;
- (3) set -- "$args0" "$args1" "$args2" ;;
- (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
- (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
- (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
- (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
- (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
- (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
- esac
fi
-# Escape application args
-save () {
- for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
- echo " "
-}
-APP_ARGS=$(save "$@")
-
-# Collect all arguments for the java command, following the shell quoting and substitution rules
-eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
-# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
-if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
- cd "$(dirname "$0")"
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
fi
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
index f9553162..a51ec4f5 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -1,84 +1,82 @@
-@if "%DEBUG%" == "" @echo off
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
-@rem Gradle startup script for Windows
+@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS=
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto init
+if %ERRORLEVEL% equ 0 goto execute
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
-goto fail
+"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-if exist "%JAVA_EXE%" goto init
+if exist "%JAVA_EXE%" goto execute
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
-goto fail
-
-:init
-@rem Get command-line arguments, handling Windows variants
-
-if not "%OS%" == "Windows_NT" goto win9xME_args
-
-:win9xME_args
-@rem Slurp the command line arguments.
-set CMD_LINE_ARGS=
-set _SKIP=2
-
-:win9xME_args_slurp
-if "x%~1" == "x" goto execute
-
-set CMD_LINE_ARGS=%*
+"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
-:omega
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/src/bootstrapTest/defaultconfigs/config/forge-client.toml b/src/bootstrapTest/defaultconfigs/config/forge-client.toml
new file mode 100644
index 00000000..8195e399
--- /dev/null
+++ b/src/bootstrapTest/defaultconfigs/config/forge-client.toml
@@ -0,0 +1,5 @@
+
+# Forge 61 client defaults used by the isolated launch checks.
+[client]
+showLoadWarnings = true
+allowMipmapLowering = false
diff --git a/src/bootstrapTest/defaultconfigs/defaultconfigs/forge-server.toml b/src/bootstrapTest/defaultconfigs/defaultconfigs/forge-server.toml
new file mode 100644
index 00000000..b1938bcf
--- /dev/null
+++ b/src/bootstrapTest/defaultconfigs/defaultconfigs/forge-server.toml
@@ -0,0 +1,13 @@
+
+#Server configuration settings
+[server]
+ #Set this to true to remove any BlockEntity that throws an error in its update method instead of closing the server and reporting a crash log. BE WARNED THIS COULD SCREW UP EVERYTHING USE SPARINGLY WE ARE NOT RESPONSIBLE FOR DAMAGES.
+ removeErroringBlockEntities = false
+ #Set this to true to remove any Entity (Note: Does not include BlockEntities) that throws an error in its tick method instead of closing the server and reporting a crash log. BE WARNED THIS COULD SCREW UP EVERYTHING USE SPARINGLY WE ARE NOT RESPONSIBLE FOR DAMAGES.
+ removeErroringEntities = false
+ #Set this to true to check the entire entity's collision bounding box for ladders instead of just the block they are in. Causes noticeable differences in mechanics so default is vanilla behavior. Default: false.
+ fullBoundingBoxLadders = false
+ #The permission handler used by the server. Defaults to forge:default_handler if no such handler with that name is registered.
+ permissionHandler = "forge:default_handler"
+ #Set this to true to enable advertising the dedicated server to local LAN clients so that it shows up in the Multiplayer screen automatically.
+ advertiseDedicatedServerToLan = true
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopCompatibilityChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopCompatibilityChecks.java
new file mode 100644
index 00000000..83cc4528
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopCompatibilityChecks.java
@@ -0,0 +1,270 @@
+package zone.moddev.mc.skysbuildingpieces.probe;
+
+import java.lang.reflect.Method;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.entity.decoration.ItemFrame;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.inventory.CraftingContainer;
+import net.minecraft.world.inventory.TransientCraftingContainer;
+import net.minecraft.world.inventory.AbstractContainerMenu;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.nbt.CompoundTag;
+import zone.moddev.mc.skysbuildingpiecesbop.probe.BopProbeItems;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.world.level.block.state.properties.IntegerProperty;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.block.entity.BlockEntityType;
+import net.minecraft.core.Direction;
+import net.minecraft.resources.Identifier;
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraftforge.common.MinecraftForge;
+import net.minecraftforge.common.capabilities.Capability;
+import net.minecraftforge.common.util.BlockSnapshot;
+import net.minecraftforge.common.util.LazyOptional;
+import net.minecraftforge.event.entity.EntityJoinLevelEvent;
+import net.minecraftforge.event.entity.player.PlayerEvent;
+import net.minecraftforge.event.level.BlockEvent;
+import net.minecraftforge.fml.ModList;
+import net.minecraftforge.common.capabilities.ForgeCapabilities;
+import net.minecraftforge.items.ItemStackHandler;
+import net.minecraftforge.registries.ForgeRegistries;
+import zone.moddev.mc.skysbuildingpieces.SkysBuildingPieces;
+import zone.moddev.mc.skysbuildingpieces.content.LegacyPieceRecipe;
+import zone.moddev.mc.skysbuildingpieces.legacy.LegacyBridge;
+import zone.moddev.mc.skysbuildingpieces.probe.legacy.LegacyTestScope;
+import zone.moddev.mc.skysbuildingpieces.legacy.MigrationEvents;
+
+/** Checks each BOP material through real events with the build-only legacy fixture. */
+public final class BopCompatibilityChecks {
+ private static String material;
+ private BopCompatibilityChecks() {
+ }
+
+ public static ItemStack legacy(int count) {
+ ItemStack stack = new ItemStack(
+ ForgeRegistries.ITEMS.getValue(Identifier.parse("buildingbricks:rock_step")), count);
+
+ BopProbeItems.setCustom(stack, NativeLegacyChecks.stack(material, count).getCompoundOrEmpty("tag").copy());
+ return stack;
+ }
+
+ public static void run(ServerLevel world) throws Exception {
+ if (!ModList.isLoaded("buildingbricks")) {
+ throw new AssertionError("Installed-mod policy checks require their build-only fixture");
+ }
+
+ boolean original = SkysBuildingPieces.forceReplace;
+
+ try (LegacyTestScope source = new LegacyTestScope(world)) {
+ for (boolean convert : new boolean[] { false, true }) {
+ SkysBuildingPieces.forceReplace = convert;
+ CompoundTag before = zone.moddev.mc.skysbuildingpieces.legacy.PiecesWorldState
+ .get(world).saveTag(new CompoundTag());
+ for (zone.moddev.mc.skysbuildingpieces.catalogue.Catalogue.MaterialDef definition
+ : zone.moddev.mc.skysbuildingpieces.catalogue.Catalogue.INSTANCE.modules
+ .get("biomesoplenty").materials.values()) {
+ material = definition.id;
+ liveInventories(world, convert);
+ newlyPlaced(world, convert);
+ unknownMaterial(world);
+ }
+ if (!convert) {
+ NativeLegacyChecks.require(before.equals(
+ zone.moddev.mc.skysbuildingpieces.legacy.PiecesWorldState.get(world)
+ .saveTag(new CompoundTag())),
+ "Disabled replacement must retain every migration counter and report");
+ }
+ }
+ } finally {
+ SkysBuildingPieces.forceReplace = original;
+ }
+
+ org.apache.logging.log4j.LogManager.getLogger().info(
+ "BOP_INSTALLED_POLICY_PASS materials=33 installed_fixture=true replacement_off=true replacement_on=true");
+ }
+
+ private static void identity(ItemStack stack, int count, boolean converted) {
+ NativeLegacyChecks.require(
+ !stack.isEmpty() && stack.getCount() == count,
+ "Live stack count changed for " + material);
+ if (converted) {
+ zone.moddev.mc.skysbuildingpieces.catalogue.Shape shape =
+ zone.moddev.mc.skysbuildingpieces.legacy.LegacyMapping.shape("buildingbricks:rock_step", 0);
+ ItemStack expected = zone.moddev.mc.skysbuildingpieces.content.Pieces.stack(
+ zone.moddev.mc.skysbuildingpieces.content.Pieces.state(material, shape, 0), count);
+ NativeLegacyChecks.require(expected != null && expected.getItem() == stack.getItem(),
+ "Stored BOP piece retains its exact material and shape: " + material);
+ } else {
+ NativeLegacyChecks.require(stack.getItem() == legacy(count).getItem(),
+ "Disabled replacement keeps the original item for " + material);
+ }
+ NativeLegacyChecks.require(BopProbeItems.hasCustom(stack) && BopProbeItems.custom(stack).equals(BopProbeItems.custom(legacy(count))),
+ "Complete unrelated custom data is retained");
+ }
+
+ private static void liveInventories(ServerLevel world, boolean convert) throws Exception {
+ ServerPlayer player = BopProbeItems.player(world);
+
+ player.getInventory().setItem(0, legacy(13));
+ player.getEnderChestInventory().setItem(0, legacy(11));
+ EntityJoinLevelEvent.BUS.post(new EntityJoinLevelEvent(player, world));
+ identity(player.getInventory().getItem(0), 13, convert);
+ identity(player.getEnderChestInventory().getItem(0), 11, convert);
+ player.getInventory().setItem(1, legacy(9));
+ PlayerEvent.LoadFromFile.BUS.post(new PlayerEvent.LoadFromFile(player, world.getServer().getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toFile(),
+ player.getUUID().toString()));
+ identity(player.getInventory().getItem(1), 9, convert);
+ world.getChunk(new BlockPos(930, 70, 930));
+
+ ItemEntity drop = new ItemEntity(world, 930, 70, 930, legacy(5));
+
+ NativeLegacyChecks.require(world.addFreshEntity(drop), "Dropped item joins the actual native world");
+ identity(drop.getItem(), 5, convert);
+
+ BlockPos position = new BlockPos(930, 70, 932);
+
+ world.setBlock(position.north(), Blocks.STONE.defaultBlockState(), 2);
+
+ ItemFrame frame = new ItemFrame(world, position, Direction.SOUTH);
+
+ frame.setItem(legacy(1));
+ world.addFreshEntity(frame);
+ identity(frame.getItem(), 1, convert);
+
+ HandlerTile tile = new HandlerTile(position);
+
+ tile.setLevel(world);
+ tile.handler.setStackInSlot(0, legacy(17));
+
+ Method scan = MigrationEvents.class.getDeclaredMethod("containers", BlockEntity.class);
+
+ scan.setAccessible(true);
+ scan.invoke(null, tile);
+ identity(tile.handler.getStackInSlot(0), 17, convert);
+
+ CompoundTag once = tile.handler.serializeNBT(world.registryAccess());
+
+ scan.invoke(null, tile);
+ NativeLegacyChecks.require(once.equals(tile.handler.serializeNBT(world.registryAccess())), "Shared handler faces convert only once");
+
+ ItemStack crate = new ItemStack(Blocks.CHEST);
+ CompoundTag tag = new CompoundTag();
+
+ tag.putBoolean("Sealed", true);
+
+ ListTag contents = new ListTag();
+
+ contents.add(BopProbeItems.save(legacy(7)));
+ tag.put("Items", contents);
+ BopProbeItems.setCustom(crate, tag);
+
+ ItemStack migrated = MigrationEvents.convert(crate, world);
+ CompoundTag recovered = BopProbeItems.custom(migrated).getListOrEmpty("Items").getCompoundOrEmpty(0);
+
+ identity(BopProbeItems.read(recovered), 7, convert);
+ NativeLegacyChecks.require(migrated.getItem() == crate.getItem() && BopProbeItems.custom(migrated).getBooleanOr("Sealed", false),
+ "Sealed container identity and custom data");
+ NativeLegacyChecks.require(MigrationEvents.convert(migrated, world) == migrated,
+ "Live nested inventory is idempotent");
+
+ CraftingContainer grid = new TransientCraftingContainer(new AbstractContainerMenu(null, 0) {
+ @Override
+ public ItemStack quickMoveStack(Player player, int slot) {
+ return ItemStack.EMPTY;
+ }
+
+ @Override
+ public boolean stillValid(Player ignored) {
+ return true;
+ }
+ }, 3, 3);
+
+ grid.setItem(0, legacy(1));
+
+ LegacyPieceRecipe recipe = new LegacyPieceRecipe();
+
+ NativeLegacyChecks.require(recipe.matches(grid.asCraftInput(), world),
+ "Explicit bridge recipe works independently of replacement");
+ identity(recipe.assemble(grid.asCraftInput()), 1, true);
+ drop.discard();
+ frame.discard();
+ world.removeBlock(position.north(), false);
+ player.getInventory().clearContent();
+ player.getEnderChestInventory().clearContent();
+ }
+
+ private static void newlyPlaced(ServerLevel world, boolean convert) {
+ BlockPos position = new BlockPos(934, 70, 932);
+ Block block = ForgeRegistries.BLOCKS.getValue(Identifier.parse("buildingbricks:rock_step"));
+ BlockState old = block.defaultBlockState().setValue((IntegerProperty) block.getStateDefinition().getProperty("meta"),
+ 11);
+
+ world.setBlock(position, old, 2);
+
+ BlockEntity tile = world.getBlockEntity(position);
+ CompoundTag tag = tile.saveWithFullMetadata(world.registryAccess());
+
+ tag.putString("material", material);
+ tag.putString("custom", "Keep the old tile in coverage");
+ tile.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create(
+ net.minecraft.util.ProblemReporter.DISCARDING, world.registryAccess(), tag));
+ BlockEvent.EntityPlaceEvent.BUS.post(new BlockEvent.EntityPlaceEvent(BlockSnapshot.create(world.dimension(), world, position),
+ Blocks.STONE.defaultBlockState(), BopProbeItems.player(world)));
+ if (convert) {
+ zone.moddev.mc.skysbuildingpieces.catalogue.Shape shape =
+ zone.moddev.mc.skysbuildingpieces.legacy.LegacyMapping.shape("buildingbricks:rock_step", 11);
+ NativeLegacyChecks.require(world.getBlockState(position).equals(
+ zone.moddev.mc.skysbuildingpieces.content.Pieces.state(material, shape,
+ zone.moddev.mc.skysbuildingpieces.legacy.LegacyMapping.orientation(shape, 11))),
+ "Newly placed BOP piece retains its exact material and orientation");
+ } else {
+ NativeLegacyChecks.require(world.getBlockState(position).equals(old),
+ "Disabled replacement keeps the original placed state");
+ }
+ NativeLegacyChecks.require((world.getBlockEntity(position) == null) == convert,
+ "Remove the material tile only after successful conversion");
+ world.removeBlock(position, false);
+ }
+
+ private static void unknownMaterial(ServerLevel world) {
+ ItemStack unknown = legacy(3);
+
+ CompoundTag unknownData = BopProbeItems.custom(unknown);
+ unknownData.putString("material", "missing:unknown");
+ BopProbeItems.setCustom(unknown, unknownData);
+
+ CompoundTag before = BopProbeItems.save(unknown);
+ ItemStack result = MigrationEvents.convert(unknown, world);
+
+ NativeLegacyChecks.require(BopProbeItems.save(result).equals(before),
+ "Installed legacy mod retains unsupported materials");
+
+ // Horizontal soil recovery belongs to Grass Slabs and must stay outside this bridge.
+ CompoundTag soil = NativeLegacyChecks.stack("minecraft:dirt", 7);
+
+ soil.putString("id", "buildingbricks:dirt_slab");
+ before = soil.copy();
+ LegacyBridge.prepareStack(soil);
+ NativeLegacyChecks.require(soil.equals(before), "Core must not take ownership of horizontal soil slabs");
+ }
+
+ public static final class HandlerTile extends BlockEntity {
+ final ItemStackHandler handler = new ItemStackHandler(2);
+ private final LazyOptional optional = LazyOptional.of(() -> handler);
+
+ HandlerTile(BlockPos position) {
+ super(net.minecraft.world.level.block.entity.BlockEntityTypes.CHEST, position, Blocks.CHEST.defaultBlockState());
+ }
+
+ @Override
+ public LazyOptional getCapability(Capability capability, Direction side) {
+ return capability == ForgeCapabilities.ITEM_HANDLER ? optional.cast()
+ : super.getCapability(capability, side);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopMigrationChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopMigrationChecks.java
new file mode 100644
index 00000000..818dc8e5
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopMigrationChecks.java
@@ -0,0 +1,121 @@
+package zone.moddev.mc.skysbuildingpieces.probe;
+
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtUtils;
+import net.minecraft.util.datafix.DataFixTypes;
+import net.minecraft.server.level.ServerLevel;
+import zone.moddev.mc.skysbuildingpieces.SkysBuildingPieces;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Catalogue;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Shape;
+import zone.moddev.mc.skysbuildingpieces.content.Pieces;
+import zone.moddev.mc.skysbuildingpieces.legacy.LegacyBridge;
+import zone.moddev.mc.skysbuildingpieces.legacy.LegacyMapping;
+import zone.moddev.mc.skysbuildingpieces.probe.legacy.LegacyTestScope;
+
+/** All BOP materials share the old generic step ID; only their saved material selects the target. */
+public final class BopMigrationChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private BopMigrationChecks() {
+ }
+
+ public static void run(ServerLevel world) throws Exception {
+ boolean forced = SkysBuildingPieces.forceReplace;
+ int checked = 0;
+
+ try (LegacyTestScope source = new LegacyTestScope(world)) {
+ SkysBuildingPieces.forceReplace = true;
+
+ for (Catalogue.MaterialDef material : Catalogue.INSTANCE.modules.get("biomesoplenty").materials.values()) {
+ for (int code = 0; code < 16; code++) {
+ CompoundTag raw = NativeLegacyChecks.chunk(LegacyTestScope.STEP_ID, code, material.id);
+
+ raw.putInt("DataVersion", 512);
+
+ CompoundTag coverage = new CompoundTag();
+
+ coverage.putInt("vanilla", 1);
+ coverage.putInt("future_addon", 9);
+ raw.getCompoundOrEmpty("Level").put("skysbuildingpieces_coverage", coverage);
+ LegacyBridge.prepareLegacyChunk(raw);
+
+ Shape shape = LegacyMapping.shape("buildingbricks:rock_step", code);
+ BlockState expected = Pieces.state(material.id, shape, LegacyMapping.orientation(shape, code));
+ BlockState staged = LegacyTestScope
+ .staged(raw.getCompoundOrEmpty("Level").getListOrEmpty("Sections").getCompoundOrEmpty(0), 0);
+
+ require(expected.equals(staged), "Wrong legacy material or shape: " + material.id + "/" + code);
+ require(coverage.getIntOr("vanilla", 0) == 1 && coverage.getIntOr("biomesoplenty", 0) == 1
+ && coverage.getIntOr("future_addon", 0) == 9, "Independent module coverage changed");
+
+ CompoundTag fixed = DataFixTypes.CHUNK.update(world.getServer().getFixerUpper(),
+ raw.copy(), 512, 1976);
+
+ require(contains(fixed, expected), "Native flattening lost " + material.id + "/" + code);
+ require(fixed.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities").isEmpty(),
+ "Native fixing invented a block entity for " + material.id + "/" + code);
+ checked++;
+ }
+
+ CompoundTag remaining = NativeLegacyChecks.chunk(LegacyTestScope.STEP_ID, 0, material.id);
+ CompoundTag coverage = new CompoundTag();
+
+ coverage.putInt("vanilla", 1);
+ coverage.putInt("biomesoplenty", 1);
+ coverage.putInt("dirt_vertical_slabs", 1);
+ remaining.getCompoundOrEmpty("Level").put("skysbuildingpieces_coverage", coverage);
+ LegacyBridge.prepareLegacyChunk(remaining);
+ require(LegacyTestScope.staged(remaining.getCompoundOrEmpty("Level").getListOrEmpty("Sections").getCompoundOrEmpty(0),
+ 0) != null, "Remaining covered BOP content was skipped: " + material.id);
+ require(remaining.getCompoundOrEmpty("Level").getCompoundOrEmpty("skysbuildingpieces_additional_module_delta")
+ .getLongOr("biomesoplenty", 0L) == 1, "Covered recovery must add only the actual conversion");
+
+ zone.moddev.mc.skysbuildingpieces.legacy.PiecesWorldState totals = zone.moddev.mc.skysbuildingpieces.legacy.PiecesWorldState
+ .get(world);
+
+ totals.addBlocks("biomesoplenty", 0);
+
+ long blocks = totals.modules.get("biomesoplenty").blocks;
+ long chunks = totals.modules.get("biomesoplenty").chunks;
+
+ LegacyBridge.finalizeLegacyChunk(remaining);
+ zone.moddev.mc.skysbuildingpieces.legacy.MigrationEvents.loadSerializedChunk(
+ world, world.getChunk(0, 0), remaining.getCompoundOrEmpty("Level"));
+ require(totals.modules.get("biomesoplenty").blocks == blocks + 1
+ && totals.modules.get("biomesoplenty").chunks == chunks,
+ "Covered recovery must not count the chunk twice");
+ }
+ } finally {
+ SkysBuildingPieces.forceReplace = forced;
+ }
+
+ LOGGER.info("BOP_NATIVE_LEGACY_PASS states=" + checked + " material_nbt=true covered_chunks=true");
+ }
+
+ private static boolean contains(CompoundTag root, BlockState expected) {
+ CompoundTag encoded = NbtUtils.writeBlockState(expected);
+ ListTag sections = root.getCompoundOrEmpty("Level").getListOrEmpty("Sections");
+
+ for (int index = 0; index < sections.size(); index++) {
+ ListTag palette = sections.getCompoundOrEmpty(index).getListOrEmpty("Palette");
+
+ for (int entry = 0; entry < palette.size(); entry++) {
+ if (encoded.equals(zone.moddev.mc.skysbuildingpieces.probe.legacy.LegacyTestScope
+ .nativeStateTag(palette.getCompoundOrEmpty(entry)))) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopRecipeAudit.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopRecipeAudit.java
new file mode 100644
index 00000000..7f699f74
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopRecipeAudit.java
@@ -0,0 +1,20 @@
+package zone.moddev.mc.skysbuildingpieces.probe;
+
+import net.minecraft.server.level.ServerLevel;
+
+/** Exercise the full catalogue without holding the server on a single tick. */
+public final class BopRecipeAudit {
+ private final RecipeChecks.Audit recipes;
+
+ public BopRecipeAudit(ServerLevel world) {
+ recipes = new RecipeChecks.Audit(world);
+ }
+
+ public boolean advance() {
+ if (!recipes.advance(24)) {
+ return false;
+ }
+ recipes.finish();
+ return true;
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopPersistenceOrderChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopPersistenceOrderChecks.java
new file mode 100644
index 00000000..e0fb99f3
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopPersistenceOrderChecks.java
@@ -0,0 +1,125 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.storage.LevelResource;
+import net.minecraft.world.level.storage.LevelStorageSource;
+import net.minecraft.world.level.storage.LevelStorageSource.LevelStorageAccess;
+import net.minecraft.world.level.storage.LevelStorageSource.LevelDirectory;
+import net.minecraftforge.fml.event.lifecycle.FMLLoadCompleteEvent;
+
+/** Exercises real capture/write callbacks; native transformer orders are checked separately. */
+final class BopPersistenceOrderChecks {
+ private static List> callbacks = List.of();
+ static void ready(FMLLoadCompleteEvent event) {
+ String order = System.getProperty("skysbuildingpieces.persistenceOrder", "");
+ if (!order.isEmpty()) {
+ event.enqueueWork(() -> arrange(order));
+ }
+ }
+
+ private static void arrange(String order) {
+ if (!order.equals("forward") && !order.equals("reverse")) {
+ throw new AssertionError("Unknown persistence order: " + order);
+ }
+ try {
+ List> selected = new ArrayList<>();
+ for (String name : new String[] {"zone.moddev.mc.skysbuildingpieces.legacy.LegacyBridge",
+ "zone.moddev.mc.skysgrassslabs.compat.LegacyWorldDataHook",
+ "zone.moddev.mc.mineralogy.patching.LegacyWorldDataHook"}) {
+ Class> owner = Class.forName(name);
+ owner.getMethod("captureLegacyLevelData", LevelStorageAccess.class, LevelDirectory.class);
+ selected.add(owner);
+ }
+ require(selected.size() == 3, "All three actual persistence hooks must be installed");
+ selected.sort(Comparator.comparing(Class::getName));
+ if (order.equals("reverse")) {
+ java.util.Collections.reverse(selected);
+ }
+ callbacks = List.copyOf(selected);
+ org.apache.logging.log4j.LogManager.getLogger().info("BOP_PERSISTENCE_ORDER_SET "
+ + order + " actual_hooks=3 mechanism=forge_level_hooks callback_order_only=true");
+ } catch (ReflectiveOperationException failure) {
+ throw new IllegalStateException("Cannot arrange the native persistence callbacks", failure);
+ }
+ }
+
+ static void verify(ServerLevel world) throws Exception {
+ File original = new File("source-level.dat");
+ if (!original.isFile() || System.getProperty("skysbuildingpieces.persistenceOrder", "").isEmpty()) {
+ return;
+ }
+ CompoundTag expected = read(original).getCompoundOrEmpty("FML");
+ if (expected.isEmpty()) {
+ return; // Named source fixtures do not need a pre-flattening registry snapshot.
+ }
+ File directory = world.getServer().getWorldPath(LevelResource.ROOT).toFile();
+ CompoundTag combined = new CompoundTag();
+ int writers = 0;
+ // Each order gets its own original input, with no sidecars from the running world.
+ Path scratch = Files.createTempDirectory(Path.of("."), "callback-order-");
+ LevelStorageSource storage = LevelStorageSource.createDefault(scratch);
+ try (LevelStorageAccess access = storage.createAccess("source")) {
+ Path source = scratch.resolve("source");
+ Files.copy(original.toPath(), source.resolve("level.dat"));
+ for (Class> owner : callbacks) {
+ owner.getMethod("captureLegacyLevelData", LevelStorageAccess.class, LevelDirectory.class)
+ .invoke(null, access, new LevelDirectory(source));
+ Method writer;
+ try {
+ writer = owner.getMethod("writeLegacyLevelData", CompoundTag.class);
+ } catch (NoSuchMethodException captureOnly) {
+ require(owner.getName().contains(".mineralogy."), "An expected write callback is missing");
+ continue;
+ }
+ writer.invoke(null, combined);
+ require(combined.getCompoundOrEmpty("FML").equals(expected), "A callback overwrote the shared snapshot");
+ writers++;
+ }
+ // Building Pieces writes its sidecar after the server preflight.
+ // The other two callbacks write theirs during early capture.
+ verifyEarlySidecars(source.toFile(), expected);
+ }
+ require(writers == 2, "The two native write wrappers must both preserve the snapshot");
+ world.getServer().saveAllChunks(true, true, true);
+ CompoundTag saved = read(new File(directory, "level.dat")).getCompoundOrEmpty("FML");
+ require(saved.equals(expected), "The complete uppercase registry snapshot changed");
+ int blockIds = verifyEarlySidecars(directory, expected);
+ CompoundTag core = read(new File(directory, "data/skysbuildingpieces_legacy_registry.dat"));
+ require(core.getCompoundOrEmpty("FML").equals(expected), "Building Pieces lost its complete sidecar");
+ org.apache.logging.log4j.LogManager.getLogger().info("BOP_PERSISTENCE_ORDER_PASS "
+ + System.getProperty("skysbuildingpieces.persistenceOrder") + " actual_hooks=3 sidecars=3 ids="
+ + blockIds + " complete_uppercase_snapshot=true");
+ }
+
+ private static int verifyEarlySidecars(File directory, CompoundTag expected) throws Exception {
+ CompoundTag blocks = expected.getCompoundOrEmpty("Registries").getCompoundOrEmpty("minecraft:blocks");
+ require(!blocks.isEmpty(), "The source must contain a real numeric registry");
+ for (String name : new String[] { "mineralogy", "skysgrassslabs" }) {
+ CompoundTag sidecar = read(new File(directory, "data/" + name + "_legacy_registry.dat"));
+ require(sidecar.getCompoundOrEmpty("Blocks").equals(blocks), name + " lost its independent sidecar");
+ }
+ return blocks.getListOrEmpty("ids").size();
+ }
+
+ private static CompoundTag read(File file) throws Exception {
+ try (FileInputStream input = new FileInputStream(file)) {
+ return NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap());
+ }
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/DecorationChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/DecorationChecks.java
new file mode 100644
index 00000000..f2374a52
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/DecorationChecks.java
@@ -0,0 +1,112 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import java.util.EnumSet;
+import java.util.Set;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.VineBlock;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import zone.moddev.mc.skysbuildingpieces.legacy.HistoricalFullBlocks;
+import zone.moddev.mc.skysbuildingpieces.legacy.LegacyBridge;
+import zone.moddev.mc.skysbuildingpiecesbop.RetiredDecorations;
+
+/** Check actual decoration geometry, drops and the saved vine attachment bits. */
+final class DecorationChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private DecorationChecks() {
+ }
+
+ static void run(ServerLevel world) {
+ BlockPos position = new BlockPos(930, 90, 930);
+ BlockState original = world.getBlockState(position);
+ BlockState above = world.getBlockState(position.above());
+ BlockState below = world.getBlockState(position.below());
+
+ try {
+ BlockState formation = RetiredDecorations.STONE_FORMATIONS.defaultBlockState();
+
+ require(formation.getCollisionShape(world, position).isEmpty(), "Formations have no collision");
+ require(!formation.isRedstoneConductor(world, position), "Formations do not become full support blocks");
+ require(Block.getDrops(formation, world, position, null).isEmpty(), "Formations have no survival drop");
+
+ Set forms = EnumSet.noneOf(RetiredDecorations.Form.class);
+
+ for (Block upper : new Block[] { Blocks.AIR, Blocks.STONE, RetiredDecorations.STONE_FORMATIONS }) {
+ for (Block lower : new Block[] { Blocks.AIR, Blocks.STONE, RetiredDecorations.STONE_FORMATIONS }) {
+ world.setBlock(position.above(), upper.defaultBlockState(), 2);
+ world.setBlock(position.below(), lower.defaultBlockState(), 2);
+ world.setBlock(position, formation, 2);
+ forms.add(RetiredDecorations.STONE_FORMATIONS.getActualState(formation, world, position)
+ .getValue(RetiredDecorations.Formation.FORM));
+ }
+ }
+
+ require(forms.size() == 8, "All eight formation profiles remain available");
+ world.removeBlock(position.above(), false);
+ world.removeBlock(position.below(), false);
+ formation.handleNeighborChanged(world, position, Blocks.AIR, null, false);
+ require(world.getBlockState(position).getBlock() == RetiredDecorations.STONE_FORMATIONS,
+ "Old formations survive missing anchors");
+
+ BlockState poison = RetiredDecorations.JELLED_POISON.defaultBlockState();
+
+ world.setBlock(position, poison, 2);
+ require(poison.getCollisionShape(world, position).bounds().equals(
+ new net.minecraft.world.phys.AABB(0, 0, 0, 1, 1, 1)), "Poison keeps full collision");
+ require(poison.getBlock().getFriction(poison, world, position, null) == .9f
+ && poison.getDestroySpeed(world, position) == .4f, "Poison friction and hardness");
+
+ java.util.List drops = Block.getDrops(poison, world, position, null);
+
+ require(drops.size() == 1
+ && drops.get(0).getItem() == net.minecraft.world.item.Item.byBlock(poison.getBlock()),
+ "Poison drops itself");
+
+ for (int metadata = 0; metadata < 16; metadata++) {
+ BlockState vine = HistoricalFullBlocks.resolve("biomesoplenty:tree_moss", metadata);
+
+ require(vine.getValue(VineBlock.SOUTH) == ((metadata & 1) != 0)
+ && vine.getValue(VineBlock.WEST) == ((metadata & 2) != 0)
+ && vine.getValue(VineBlock.NORTH) == ((metadata & 4) != 0)
+ && vine.getValue(VineBlock.EAST) == ((metadata & 8) != 0), "Saved vine orientation " + metadata);
+
+ CompoundTag stack = new CompoundTag();
+
+ stack.putString("id", "skysbuildingpiecesbop:legacy_bop_tree_moss");
+ stack.putByte("Count", (byte) 37);
+ stack.putShort("Damage", (short) metadata);
+
+ CompoundTag tag = new CompoundTag();
+
+ tag.putString("custom", "Preserved decoration");
+ stack.put("tag", tag);
+ LegacyBridge.prepareStack(stack);
+
+ ItemStack decoded = BopProbeItems.read(stack);
+
+ require(decoded.getCount() == 37 && BopProbeItems.custom(decoded).equals(tag)
+ && decoded.getItem() == net.minecraft.world.item.Item.byBlock(vine.getBlock()),
+ "Vine item identity, count and custom data");
+ }
+
+ LOGGER.info(
+ "BOP_NATIVE_DECORATIONS_PASS profiles=8 vine_states=16 collision=true drops=true NBT=true");
+ } finally {
+ world.setBlock(position, original, 2);
+ world.setBlock(position.above(), above, 2);
+ world.setBlock(position.below(), below, 2);
+ }
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/ForwardUpgradeChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/ForwardUpgradeChecks.java
new file mode 100644
index 00000000..e0175291
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/ForwardUpgradeChecks.java
@@ -0,0 +1,563 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileReader;
+import java.io.InputStream;
+import java.util.Map;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.TagParser;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.level.block.entity.ChestBlockEntity;
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Catalogue;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Shape;
+import zone.moddev.mc.skysbuildingpieces.content.Pieces;
+import zone.moddev.mc.skysbuildingpieces.legacy.HistoricalFullBlocks;
+import zone.moddev.mc.skysbuildingpieces.legacy.PiecesWorldState;
+
+/** Load every saved position through the real server, including chunks outside spawn. */
+final class ForwardUpgradeChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private static final JsonObject TARGET_UPGRADES = targetUpgrades();
+
+ private ForwardUpgradeChecks() {
+ }
+
+ static void run(MinecraftServer server, File manifestFile) throws Exception {
+ JsonObject manifest = read(manifestFile);
+ ServerLevel world = server.getLevel(net.minecraft.world.level.Level.OVERWORLD);
+
+ run(world, manifest);
+ }
+
+ static void biomesOnly(MinecraftServer server, File manifestFile) throws Exception {
+ checkBiomes(server.getLevel(net.minecraft.world.level.Level.OVERWORLD), read(manifestFile));
+ }
+
+ private static JsonObject read(File manifestFile) throws Exception {
+ try (FileReader reader = new FileReader(manifestFile)) {
+ JsonObject manifest = new JsonParser().parse(reader).getAsJsonObject();
+
+ if (manifest.has("manifest")) {
+ JsonObject source = manifest;
+
+ manifest = source.getAsJsonObject("manifest");
+ if (source.has("source_saved_states")) {
+ manifest.add("source_saved_states", source.get("source_saved_states"));
+ }
+ }
+
+ return manifest;
+ }
+ }
+
+ private static void run(ServerLevel world, JsonObject manifest) throws Exception {
+ int states = 0;
+ java.util.Map> doors = SourceNeighbourChecks.expectedDoors(
+ world, TARGET_UPGRADES.getAsJsonObject("blocks"));
+ int normalisedDoors = doors.size();
+ int nativePlantNormalisations = 0;
+ int flowingSamples = 0;
+ java.util.Set verifiedFlows = SourceFluidChecks.verify(world);
+ java.util.Map retainedRocks = retainedNamedRocks(manifest);
+ java.util.Map sourceStates = new java.util.HashMap<>();
+ if (manifest.has("source_saved_states")) {
+ for (JsonElement value : manifest.getAsJsonArray("source_saved_states")) {
+ JsonObject row = value.getAsJsonObject();
+ sourceStates.put(position(row), TagParser.parseCompoundFully(row.get("state").getAsString()));
+ }
+ }
+
+ java.util.List blockFailures = new java.util.ArrayList<>();
+ for (JsonElement element : manifest.getAsJsonArray("blocks")) {
+ try {
+ JsonObject row = element.getAsJsonObject();
+ BlockPos position = position(row);
+ BlockState actual = world.getBlockState(position);
+ BlockState expected;
+
+ if (retainedRocks.containsKey(position)) {
+ expected = retainedRocks.get(position);
+ } else if (row.has("role") && row.get("role").getAsString().equals("world")) {
+ JsonObject target = new JsonParser().parse(row.getAsJsonObject("target").toString())
+ .getAsJsonObject();
+
+ target.addProperty("id", currentTarget("blocks", target.get("id").getAsString()));
+ // The numeric source had a single root, before BOP's later two-block mangrove plant.
+ if (!manifest.has("source_saved_states") && row.get("id").getAsString().equals("biomesoplenty:plant_1")
+ && row.get("meta").getAsInt() == 9) {
+ target.addProperty("id", "biomesoplenty:dead_grass");
+ }
+ if (target.get("id").getAsString().equals("biomesoplenty:cattail")
+ && !target.getAsJsonObject("properties").has("half")) {
+ target.addProperty("id", "biomesoplenty:dead_grass");
+ }
+ if (target.get("id").getAsString().equals("biomesoplenty:white_sandstone_wall")
+ || target.get("id").getAsString().equals("biomesoplenty:mud_brick_wall")) {
+ JsonObject properties = target.getAsJsonObject("properties");
+ for (String direction : new String[] {"north", "south", "east", "west"}) {
+ if (!properties.has(direction)) continue;
+ String value = properties.get(direction).getAsString();
+ if (value.equals("true") || value.equals("false")) {
+ properties.addProperty(direction, value.equals("true") ? "low" : "none");
+ }
+ }
+ }
+ expected = zone.moddev.mc.skysbuildingpiecesbop.BopWorldMappings.state(target);
+ } else if (row.has("role") || row.has("owned_decoration")) {
+ expected = HistoricalFullBlocks.resolve(row.get("id").getAsString(), row.get("meta").getAsInt());
+ } else if (row.get("shape").getAsString().equals("FULL")) {
+ expected = Pieces.nativeState(Catalogue.INSTANCE.materials.get(row.get("material").getAsString()),
+ "full");
+ } else {
+ expected = Pieces.state(row.get("material").getAsString(),
+ Shape.valueOf(row.get("shape").getAsString()), row.get("orientation").getAsInt());
+ }
+
+ // These older range fixtures include unsupported plants and incomplete tall plants.
+ // Keep their saved properties, then use the same native neighbour validation as chunk loading.
+ if (manifest.has("source_saved_states")
+ && (expected.getBlock() instanceof net.minecraft.world.level.block.VegetationBlock
+ || expected.getBlock() instanceof net.minecraft.world.level.block.VineBlock)) {
+ CompoundTag original = sourceStates.get(position);
+ if (original != null) {
+ for (String key : original.getCompoundOrEmpty("Properties").keySet()) {
+ var property = expected.getBlock().getStateDefinition().getProperty(key);
+ if (property != null) {
+ expected = sourceProperty(expected, property, original.getCompoundOrEmpty("Properties").getStringOr(key, ""));
+ }
+ }
+ }
+ BlockState validated = net.minecraft.world.level.block.Block.updateFromNeighbourShapes(expected, world, position);
+ // Border updates can remain pending. A valid plant still has just one permitted state;
+ // an unsupported fixture plant may be unchanged or have its native update applied.
+ if (actual != expected) {
+ expected = validated;
+ nativePlantNormalisations++;
+ }
+ }
+
+ CompoundTag sourceState = sourceStates.get(position);
+ if (verifiedFlows.contains(position) && expected.is(net.minecraft.world.level.block.Blocks.WATER)
+ && sourceState != null && sourceState.getStringOr("Name", "").equals("minecraft:water")
+ && Integer.parseInt(sourceState.getCompoundOrEmpty("Properties").getStringOr("level", "")) > 0) {
+ // The native data fixer preserved this exact source state. Ticking can then move its water.
+ // Source water and waterlogged pieces retain their usual exact-state checks.
+ require(actual.isAir() || actual.is(net.minecraft.world.level.block.Blocks.WATER),
+ "A flowing-water sample became an unrelated block at " + position + ": " + actual);
+ expected = actual;
+ flowingSamples++;
+ }
+
+ require(actual.getBlock() == expected.getBlock(),
+ "Wrong saved block at " + position + ": " + actual + ", expected " + expected);
+
+ java.util.List doorStates = doors.remove(position);
+ if (doorStates != null) {
+ require(expected.getBlock() == doorStates.get(0).getBlock(), "A source door changed identity");
+ require(doorStates.contains(actual), "A source door matches neither its original state nor native updates at "
+ + position + ": " + actual + ", expected one of " + doorStates);
+ expected = actual;
+ }
+
+ // Connections and snow are derived from neighbours. The palette metadata and persisted orientation still
+ // have to match exactly.
+ for (var property : expected.getValues().toList()) {
+ String name = property.property().getName();
+
+ if (name.equals("north") || name.equals("south") || name.equals("east") || name.equals("west")
+ || name.equals("up") || name.equals("down") || name.equals("snowy") || name.equals("in_wall")
+ || name.equals("lowered_support") || name.equals("shape") || name.equals("connections")) {
+ continue;
+ }
+
+ require(property.value().equals(actual.getValue(property.property())),
+ "Wrong " + name + " at " + position + ": " + actual + ", expected " + expected);
+ }
+
+ states++;
+ } catch (AssertionError failure) {
+ blockFailures.add(failure.getMessage());
+ }
+ }
+ require(blockFailures.isEmpty(), "Saved block mismatches (" + blockFailures.size() + "):\n"
+ + String.join("\n", blockFailures.subList(0, Math.min(100, blockFailures.size()))));
+
+ if (flowingSamples > 0) {
+ LOGGER.info("BOP_NATIVE_FLOW_UPDATES_PASS dynamic_cells=" + flowingSamples + " datafix_verified=true");
+ }
+ LOGGER.info("BOP_NATIVE_PLANT_NORMALIZATION_PASS applied=" + nativePlantNormalisations
+ + " source_bound=true native_updates=true");
+ require(doors.isEmpty(), "Some source door controls were not checked");
+ if (normalisedDoors > 0) {
+ LOGGER.info("BOP_NATIVE_DOOR_NORMALIZATION_PASS doors=" + normalisedDoors);
+ }
+
+ int baskets = 0;
+ int units = 0;
+
+ for (JsonElement element : manifest.getAsJsonArray("stacks")) {
+ JsonObject row = element.getAsJsonObject();
+ BlockPos chestPosition = position(row);
+
+ world.getChunk(chestPosition.getX() >> 4, chestPosition.getZ() >> 4);
+ require(world.getBlockEntity(chestPosition) instanceof ChestBlockEntity,
+ "Saved chest is missing at " + chestPosition + ": " + world.getBlockState(chestPosition));
+
+ BlockPos storagePosition = SourceNeighbourChecks.containerPosition(world, chestPosition);
+ ChestBlockEntity chest = (ChestBlockEntity) world.getBlockEntity(storagePosition);
+ ItemStack actual = chest.getItem(row.get("slot").getAsInt());
+ CompoundTag original = TagParser.parseCompoundFully(row.get("item").getAsString());
+
+ require(actual.getCount() == original.getByteOr("Count", (byte) 0), "Saved stack count changed: " + row + ", actual " + actual);
+
+ if (row.has("basket")) {
+ require(net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(actual.getItem()).toString().equals("skysbuildingpiecesbop:flower_basket"),
+ "Saved basket became another item");
+
+ ListTag contents = BopProbeItems.custom(actual).getListOrEmpty("Items");
+ int expectedUnits = 0;
+ ListTag source = original.getCompoundOrEmpty("tag").getListOrEmpty("Items");
+
+ require(contents.size() == source.size(), "A basket lost stored slots: " + row.get("basket"));
+
+ var inventory = new zone.moddev.mc.skysbuildingpiecesbop.basket.BasketInventory(actual, world.registryAccess());
+ for (int slot = 0; slot < source.size(); slot++) {
+ CompoundTag before = source.getCompoundOrEmpty(slot);
+ CompoundTag after = contents.getCompoundOrEmpty(slot);
+
+ require(before.getByteOr("Slot", (byte) 0) == after.getByteOr("Slot", (byte) 0)
+ && before.getByteOr("Count", (byte) 0) == zone.moddev.mc.skysbuildingpiecesbop.basket.BasketData.count(after),
+ "Basket slot/count changed");
+ ItemStack stored = inventory.getItem(before.getByteOr("Slot", (byte) 0));
+ require(stored.getCount() == before.getByteOr("Count", (byte) 0), "Basket could not read the saved slot");
+
+ String expectedId = currentItemTarget(plant(before));
+
+ require(after.getStringOr("id", "").equals(expectedId),
+ "Basket plant changed: " + before + ", expected " + expectedId + ", actual " + after);
+ expectedUnits += before.getByteOr("Count", (byte) 0);
+ }
+
+ units += expectedUnits;
+ baskets++;
+ } else if (row.has("native_item")) {
+ require(net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(actual.getItem()).toString()
+ .equals(currentItemTarget(row.get("target_item").getAsString())),
+ "Saved native BOP item changed: " + row + ", actual " + actual);
+ require(original.getCompoundOrEmpty("tag").equals(BopProbeItems.custom(actual)), "Saved native item custom data changed");
+ } else {
+ ItemStack expected = Pieces.legacyItem(original.getStringOr("id", ""), original.getShortOr("Damage", (short) 0), 1);
+
+ require(expected.getItem() == actual.getItem(), "Saved piece item changed: " + row);
+ require(original.getCompoundOrEmpty("tag").equals(BopProbeItems.custom(actual)), "Saved custom item NBT changed: " + row);
+ }
+ }
+
+ PiecesWorldState state = PiecesWorldState.get(world);
+ BlockPos shovelPosition = new BlockPos(600, 72, 546);
+
+ world.getChunk(shovelPosition.getX() >> 4, shovelPosition.getZ() >> 4);
+
+ ItemStack shovel = ((ChestBlockEntity) world.getBlockEntity(
+ SourceNeighbourChecks.containerPosition(world, shovelPosition))).getItem(0);
+ var efficiency = world.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.ENCHANTMENT)
+ .getOrThrow(net.minecraft.world.item.enchantment.Enchantments.EFFICIENCY);
+
+ require(shovel.getItem() == net.minecraft.world.item.Items.DIAMOND_SHOVEL && shovel.getDamageValue() == 17
+ && shovel.getHoverName().getString().equals("A saved slope shovel")
+ && net.minecraft.world.item.enchantment.EnchantmentHelper
+ .getItemEnchantmentLevel(efficiency, shovel) == 3,
+ "Saved shovel identity, damage, name or enchantment changed");
+ require(state.blocks == 17 && state.items == 31 && state.chunks == 7, "Existing vanilla totals changed");
+
+ PiecesWorldState.Totals bop = state.modules.get("biomesoplenty");
+
+ require(bop != null && bop.blocks == 23 && bop.items == 19 && bop.chunks == 1, "Existing BOP totals changed");
+ require(baskets == 8 && units == 1500, "Saved basket contract changed");
+
+ if (manifest.has("biomes")) {
+ checkBiomes(world, manifest);
+ }
+
+ checkSavedRecoveryData(world);
+
+ LOGGER.info("BUILDING_PIECES_BOP_FORWARD_UPGRADE_PASS states=" + states + " baskets=" + baskets
+ + " units=" + units);
+ }
+
+ static void checkSavedRecoveryData(ServerLevel world) throws Exception {
+ File first = new File("first-save/data");
+
+ // Delayed profiles deliberately open additional chunks after the first save.
+ if (!first.isDirectory() || Boolean.getBoolean("skysbuildingpieces.bopDelayedBiomes")) {
+ return;
+ }
+
+ world.getServer().saveAllChunks(true, true, true);
+ File current = new File(world.getServer()
+ .getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toFile(), "data");
+ int checked = 0;
+ int restoredOptionalSidecars = 0;
+
+ for (String name : new String[] {"skysbuildingpieces_world_state.dat",
+ "skysbuildingpieces_legacy_registry.dat", "skysbuildingpiecesbop_named_registry.dat",
+ "skysgrassslabs_legacy_registry.dat", "mineralogy_legacy_registry.dat"}) {
+ File before = new File(first, name);
+ File after = new File(current, name);
+ if (!before.isFile() && after.isFile() && copiedOptionalRegistry(first, after)) {
+ restoredOptionalSidecars++;
+ continue;
+ }
+ require(before.isFile() == after.isFile(), "Recovery data appeared or disappeared on reload: " + name);
+
+ if (before.isFile()) {
+ try (InputStream original = new FileInputStream(before);
+ InputStream reloaded = new FileInputStream(after)) {
+ CompoundTag firstData = net.minecraft.nbt.NbtIo.readCompressed(original, net.minecraft.nbt.NbtAccounter.unlimitedHeap());
+ CompoundTag currentData = net.minecraft.nbt.NbtIo.readCompressed(reloaded, net.minecraft.nbt.NbtAccounter.unlimitedHeap());
+ if (name.equals("skysbuildingpieces_world_state.dat")) {
+ require((firstData.get("data") instanceof net.minecraft.nbt.CompoundTag) && (currentData.get("data") instanceof net.minecraft.nbt.CompoundTag),
+ "The saved-data wrapper is missing its contents");
+ int previousVersion = firstData.getIntOr("DataVersion", 0);
+ int currentVersion = currentData.getIntOr("DataVersion", 0);
+ int targetVersion = net.minecraft.SharedConstants.getCurrentVersion().dataVersion().version();
+ require(previousVersion <= targetVersion
+ && (currentVersion == previousVersion || currentVersion == targetVersion),
+ "Unexpected saved-data version change");
+ // Minecraft may stamp an unchanged old SavedData wrapper on its next save.
+ // The complete mod data and every other wrapper field must still match.
+ firstData.remove("DataVersion");
+ currentData.remove("DataVersion");
+ }
+ require(firstData.equals(currentData), "Saved recovery data changed on reload: " + name);
+ }
+ checked++;
+ }
+ }
+
+ require(checked > 0, "The reload profile has no saved recovery data");
+ LOGGER.info("BOP_RECOVERY_DATA_RELOAD_PASS files={} copied_optional_registries={}",
+ checked, restoredOptionalSidecars);
+ }
+
+ private static boolean copiedOptionalRegistry(File first, File after) throws Exception {
+ boolean grass = after.getName().equals("skysgrassslabs_legacy_registry.dat");
+ if (!grass && !after.getName().equals("mineralogy_legacy_registry.dat")) {
+ return false;
+ }
+ CompoundTag source = readCompressed(new File("source-level.dat"));
+ if ((source.get("FML") instanceof net.minecraft.nbt.CompoundTag) || source.getCompoundOrEmpty("Data").getIntOr("DataVersion", 0) < 1451) {
+ return false;
+ }
+ // A named source can carry the old FML table in the core sidecar
+ // alone. Forge writes it to level.dat on the first save, where the
+ // other mods can read it on the next start.
+ File coreSidecar = new File(first, "skysbuildingpieces_legacy_registry.dat");
+ if (!coreSidecar.isFile()) {
+ return false;
+ }
+ CompoundTag fml = readCompressed(coreSidecar).getCompoundOrEmpty("FML");
+ CompoundTag blocks = fml.getCompoundOrEmpty("Registries").getCompoundOrEmpty("minecraft:blocks");
+ CompoundTag expected = new CompoundTag();
+ expected.put("Blocks", blocks);
+ if (grass) {
+ expected.put("FML", fml);
+ }
+ return !blocks.isEmpty() && expected.equals(readCompressed(after));
+ }
+
+ private static CompoundTag readCompressed(File file) throws java.io.IOException {
+ try (InputStream input = new FileInputStream(file)) {
+ return net.minecraft.nbt.NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap());
+ }
+ }
+
+ private static java.util.Map retainedNamedRocks(JsonObject manifest) throws Exception {
+ java.util.Map states = new java.util.HashMap<>();
+
+ if (manifest.has("source_saved_states")) {
+ for (JsonElement element : manifest.getAsJsonArray("source_saved_states")) {
+ JsonObject row = element.getAsJsonObject();
+ CompoundTag saved = TagParser.parseCompoundFully(row.get("state").getAsString());
+
+ // Installing Mineralogy later does not replace an already named fallback block.
+ // Its bridge recipe is the player's explicit conversion route.
+ if (saved.getStringOr("Name", "").equals("skysbuildingpiecesbop:retired_rocks")) {
+ CompoundTag properties = saved.getCompoundOrEmpty("Properties");
+ int slot = properties.contains("variant") ? Integer.parseInt(properties.getStringOr("variant", "")) : 0;
+ states.put(position(row), zone.moddev.mc.skysbuildingpiecesbop.RetiredRocks.state(slot));
+ }
+ }
+ }
+ return states;
+ }
+
+ private static void checkBiomes(ServerLevel world, JsonObject manifest) {
+ require(manifest.has("biomes") && manifest.getAsJsonArray("biomes").size() == 73,
+ "The biome regression fixture is incomplete");
+
+ File delayedMarker = new File("delayed-biomes-first-pass.txt");
+ boolean delay = Boolean.getBoolean("skysbuildingpieces.bopDelayedBiomes");
+ boolean first = delay && !delayedMarker.isFile();
+ java.util.Set deferred = new java.util.HashSet<>();
+ int number = 0;
+
+ for (JsonElement element : manifest.getAsJsonArray("biomes")) {
+ JsonObject row = element.getAsJsonObject();
+ BlockPos position = position(row);
+
+ if ((number++ & 1) != 0 || TARGET_UPGRADES.getAsJsonObject("biomes").has(row.get("id").getAsString())) {
+ deferred.add(net.minecraft.world.level.ChunkPos.pack(position.getX() >> 4, position.getZ() >> 4));
+ }
+ }
+
+ int checked = 0;
+
+ for (JsonElement element : manifest.getAsJsonArray("biomes")) {
+ JsonObject row = element.getAsJsonObject();
+ BlockPos position = position(row);
+
+ if (first && deferred.contains(net.minecraft.world.level.ChunkPos.pack(
+ position.getX() >> 4, position.getZ() >> 4))) {
+ require(world.getChunkSource().getChunkNow(position.getX() >> 4, position.getZ() >> 4)
+ == null, "A deferred biome chunk was loaded during the first pass");
+ continue;
+ }
+
+ world.getChunk(position(row));
+
+ String actual = world.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.BIOME)
+ .getKey(world.getBiome(position(row)).value()).toString();
+
+ require(actual.equals(currentTarget("biomes", row.get("target_biome").getAsString())),
+ "Saved BOP biome changed after loading its chunk: " + row + ", actual " + actual);
+ checked++;
+ }
+
+ if (first) {
+ require(checked > 0 && checked < 73 && !deferred.isEmpty(), "Delayed biome coverage is empty");
+ try {
+ java.nio.file.Files.write(delayedMarker.toPath(), ("deferred_chunks=" + deferred.size() + "\n")
+ .getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ } catch (java.io.IOException failure) {
+ throw new IllegalStateException("Cannot retain delayed-biome evidence", failure);
+ }
+ } else {
+ require(checked == 73, "A saved biome was not checked");
+ }
+
+ LOGGER.info("BOP_NATIVE_BIOMES_PASS biomes=" + checked + " loaded_chunks=true delayed_first=" + first);
+ }
+
+ private static String plant(CompoundTag original) {
+ String id = original.getStringOr("id", "");
+ int metadata = original.getShortOr("Damage", (short) 0);
+
+ if (id.equals("minecraft:sapling")) {
+ return "minecraft:" + new String[] { "oak", "spruce", "birch", "jungle", "acacia", "dark_oak" }[metadata]
+ + "_sapling";
+ }
+ if (id.equals("minecraft:waterlily") || id.equals("biomesoplenty:waterlily")) {
+ return "minecraft:lily_pad";
+ }
+ if (id.equals("biomesoplenty:flower_0")) {
+ return metadata == 4 ? "biomesoplenty:blue_hydrangea"
+ : metadata == 8 ? "biomesoplenty:violet" : "minecraft:oxeye_daisy";
+ }
+ if (id.equals("biomesoplenty:plant_1")) {
+ return metadata == 4 ? "biomesoplenty:cattail" : "minecraft:cactus";
+ }
+ if (id.equals("biomesoplenty:sapling_0")) {
+ return "biomesoplenty:" + (metadata == 0 ? "yellow_autumn" : metadata == 4 ? "umbran" : "fir") + "_sapling";
+ }
+ if (id.equals("biomesoplenty:sapling_1")) {
+ return "biomesoplenty:" + (metadata == 3 ? "maple" : metadata == 5 ? "flowering_oak" : "jacaranda")
+ + "_sapling";
+ }
+ if (id.equals("biomesoplenty:sapling_2")) {
+ return "biomesoplenty:" + (metadata == 1 ? "palm" : metadata == 2 ? "redwood" : "willow") + "_sapling";
+ }
+
+ return id;
+ }
+
+ private static JsonObject targetUpgrades() {
+ try (java.io.InputStreamReader reader = new java.io.InputStreamReader(
+ zone.moddev.mc.skysbuildingpiecesbop.RetiredRocks.class.getResourceAsStream(
+ "/assets/skysbuildingpiecesbop/catalogue/named_legacy.json"),
+ java.nio.charset.StandardCharsets.UTF_8)) {
+ return new JsonParser().parse(reader).getAsJsonObject();
+ } catch (java.io.IOException failure) {
+ throw new IllegalStateException("Cannot read the target upgrade contract", failure);
+ }
+ }
+
+ static boolean isRecentBlockRename(String id) {
+ return TARGET_UPGRADES.getAsJsonObject("renamed_1_21_11").has(id)
+ || TARGET_UPGRADES.getAsJsonObject("renamed_26_2").has(id)
+ || TARGET_UPGRADES.getAsJsonObject("renamed_26_3").has(id);
+ }
+
+ static String currentTarget(String group, String id) {
+ if (group.equals("blocks") || group.equals("items")) {
+ if (id.equals("minecraft:grass_path")) {
+ return "minecraft:dirt_path";
+ }
+ if (id.equals("minecraft:grass")) {
+ return "minecraft:short_grass";
+ }
+ }
+ JsonObject mappings = TARGET_UPGRADES.getAsJsonObject(group);
+
+ String target = mappings.has(id) ? mappings.get(id).getAsString() : id;
+
+ if (group.equals("blocks") || group.equals("items")) {
+ for (String release : new String[] {"renamed_1_21_11", "renamed_26_2", "renamed_26_3"}) {
+ JsonObject aliases = TARGET_UPGRADES.getAsJsonObject(release);
+ if (aliases.has(target)) {
+ target = aliases.get(target).getAsString();
+ }
+ }
+ }
+
+ if (group.equals("biomes")) {
+ if (target.equals("minecraft:nether")) return "minecraft:nether_wastes";
+ if (target.equals("minecraft:deep_warm_ocean")) return "minecraft:warm_ocean";
+ return net.minecraft.util.datafix.fixes.CavesAndCliffsRenames.RENAMES.getOrDefault(target, target);
+ }
+ return target;
+ }
+
+ static String currentItemTarget(String id) {
+ String target = currentTarget("blocks", currentTarget("items", id));
+ return target.equals("minecraft:potted_poplar_sapling") ? "minecraft:poplar_sapling" : target;
+ }
+
+ private static > BlockState sourceProperty(BlockState state,
+ net.minecraft.world.level.block.state.properties.Property property, String value) {
+ return state.setValue(property, property.getValue(value).orElseThrow(
+ () -> new AssertionError("A saved plant property cannot be recovered: " + property.getName() + "=" + value)));
+ }
+
+ private static BlockPos position(JsonObject row) {
+ return new BlockPos(row.get("x").getAsInt(), row.get("y").getAsInt(), row.get("z").getAsInt());
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/MaterialChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/MaterialChecks.java
new file mode 100644
index 00000000..a601f6af
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/MaterialChecks.java
@@ -0,0 +1,166 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import java.util.List;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.core.Direction;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraftforge.registries.ForgeRegistries;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Catalogue;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Geometry;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Shape;
+import zone.moddev.mc.skysbuildingpieces.content.Pieces;
+import zone.moddev.mc.skysbuildingpieces.content.PieceBlock;
+import zone.moddev.mc.skysbuildingpiecesbop.SkysBuildingPiecesBop;
+
+/** Exercise the complete resolved catalogue with the real native block classes. */
+final class MaterialChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private MaterialChecks() {
+ }
+
+ static void run(ServerLevel world) {
+ BlockPos position = new BlockPos(-700, 90, -700);
+ BlockState previous = world.getBlockState(position);
+ int checked = 0;
+
+ try {
+ retiredMining(world, position);
+ for (Catalogue.MaterialDef material : Catalogue.INSTANCE.modules.get("biomesoplenty").materials.values()) {
+ BlockState full = Pieces.nativeState(material, "full");
+
+ for (Shape shape : new Shape[] { Shape.SLAB, Shape.VERTICAL_SLAB, Shape.HORIZONTAL_STEP,
+ Shape.VERTICAL_STEP, Shape.CORNER, Shape.STAIRS, Shape.WALL }) {
+ for (int orientation = 0; orientation < shape.states; orientation++) {
+ BlockState state = Pieces.state(material.id, shape, orientation);
+
+ if (state == null) {
+ continue;
+ }
+
+ world.setBlock(position, state, 2);
+
+ ItemStack item = Pieces.stack(state, 3);
+
+ require(item != null && !item.isEmpty() && item.getCount() == 3, "Piece item " + material.id);
+ require(world.getBlockEntity(position) == null,
+ "Material pieces must not create block entities");
+ require(state.canBeReplaced() == full.canBeReplaced(), "Native block replaceability " + material.id);
+ require(state.getDestroySpeed(world, position) == full.getDestroySpeed(world, position), "Native hardness " + material.id);
+ Catalogue.MaterialDef placedMaterial = state.getBlock() instanceof PieceBlock
+ ? ((PieceBlock) state.getBlock()).definition(state) : null;
+ // Retired rocks keep their declared resistance even when Mineralogy supplies the full block.
+ // Reused native slabs and stairs retain their own native properties.
+ float resistance = placedMaterial != null && placedMaterial.explosionResistance() >= 0
+ ? placedMaterial.explosionResistance()
+ : full.getBlock().getExplosionResistance(full, world, position, null);
+
+ require(state.getBlock().getExplosionResistance(state, world, position, null) == resistance,
+ "Material resistance " + material.id);
+
+ for (Direction face : Direction.values()) {
+ int flammability = placedMaterial != null && placedMaterial.flammability() >= 0
+ ? placedMaterial.flammability()
+ : full.getBlock().getFlammability(full, world, position, face);
+ int spread = placedMaterial != null && placedMaterial.fireSpread() >= 0
+ ? placedMaterial.fireSpread()
+ : full.getBlock().getFireSpreadSpeed(full, world, position, face);
+
+ require(state.getBlock().getFlammability(state, world, position, face) == flammability,
+ "Material flammability " + material.id);
+ require(state.getBlock().getFireSpreadSpeed(state, world, position, face) == spread,
+ "Material fire spread " + material.id);
+ }
+ if (shape != Shape.WALL) {
+ List boxes = state.getCollisionShape(world, position).toAabbs();
+ int occupied = 0;
+
+ for (int cellY = 0; cellY < 2; cellY++) {
+ for (int cellZ = 0; cellZ < 2; cellZ++) {
+ for (int cellX = 0; cellX < 2; cellX++) {
+ double sampleX = .25 + .5 * cellX;
+ double sampleY = .25 + .5 * cellY;
+ double sampleZ = .25 + .5 * cellZ;
+
+ if (boxes.stream().anyMatch(box -> box.contains(sampleX, sampleY, sampleZ))) {
+ occupied |= 1 << (cellY * 4 + cellZ * 2 + cellX);
+ }
+ }
+ }
+ }
+
+ require(occupied == Geometry.mask(shape, orientation), "Collision geometry " + material.id
+ + " / " + shape + " / " + orientation + ": " + occupied);
+ }
+
+ if (state.hasProperty(net.minecraft.world.level.block.SlabBlock.WATERLOGGED)) {
+ BlockState wet = state.setValue(net.minecraft.world.level.block.SlabBlock.WATERLOGGED, true);
+
+ world.setBlock(position, wet, 2);
+ require(world.getFluidState(position).getType() == net.minecraft.world.level.material.Fluids.WATER,
+ "Waterlogged material lost its fluid: " + material.id + " / " + shape);
+ require(net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,
+ net.minecraft.nbt.NbtUtils.writeBlockState(wet)).equals(wet),
+ "Waterlogged orientation did not survive serialization: " + material.id);
+ world.setBlock(position, state, 2);
+ }
+
+ checked++;
+ }
+ }
+ }
+
+ long registered = ForgeRegistries.BLOCKS.getValues().stream()
+ .filter(block -> net.minecraftforge.registries.ForgeRegistries.BLOCKS.getKey(block).getNamespace().equals(SkysBuildingPiecesBop.ID)).count();
+
+ require(registered == 74 + 180 + 7, "Complete add-on block catalogue: " + registered);
+ LOGGER.info("BOP_NATIVE_MATERIALS_PASS states=" + checked + " blocks=" + registered);
+ } finally {
+ world.setBlock(position, previous, 2);
+ }
+ }
+
+ private static void retiredMining(ServerLevel world, BlockPos position) {
+ net.minecraft.server.level.ServerPlayer player = BopProbeItems.player(world,
+ new com.mojang.authlib.GameProfile(java.util.UUID.fromString("f8ca0294-a7f9-453f-9533-66fc7c45d698"),
+ "RetiredRockProbe"));
+ net.minecraft.world.item.Item[] tools = {
+ net.minecraft.world.item.Items.WOODEN_PICKAXE, net.minecraft.world.item.Items.STONE_PICKAXE,
+ net.minecraft.world.item.Items.GOLDEN_PICKAXE, net.minecraft.world.item.Items.IRON_PICKAXE,
+ net.minecraft.world.item.Items.DIAMOND_PICKAXE, net.minecraft.world.item.Items.NETHERITE_PICKAXE
+ };
+ float[] hardness = { 5, 1.5f, 1.5f, 1.5f, 1.5f, 1, 1 };
+ int checked = 0;
+ for (int variant = 0; variant < hardness.length; variant++) {
+ BlockState state = zone.moddev.mc.skysbuildingpiecesbop.RetiredRocks.state(variant);
+ world.setBlock(position, state, 2);
+ require(state.getDestroySpeed(world, position) == hardness[variant], "Retired-rock hardness " + variant);
+ for (int tool = -1; tool < tools.length; tool++) {
+ ItemStack held = tool < 0 ? ItemStack.EMPTY : new ItemStack(tools[tool]);
+ player.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND, held);
+ boolean expected = tool >= 0 && (variant != 0 || tool >= 3);
+ require(state.canHarvestBlock(world, position, player) == expected,
+ "Retired-rock tool tier " + variant + " / " + tool);
+ BlockState reference = (variant == 0 ? net.minecraft.world.level.block.Blocks.DIAMOND_ORE
+ : net.minecraft.world.level.block.Blocks.STONE).defaultBlockState();
+ float progress = player.getDestroySpeed(reference, position) / hardness[variant] / (expected ? 30 : 100);
+ require(Math.abs(state.getDestroyProgress(player, world, position) - progress) < .000001f,
+ "Retired-rock mining speed " + variant + " / " + tool);
+ checked++;
+ }
+ }
+ player.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND, ItemStack.EMPTY);
+ LOGGER.info("BOP_RETIRED_MINING_PASS variants=7 tools=6 empty_hand=true checks=" + checked);
+ }
+
+ private static void require(boolean value, String message) {
+ if (!value) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NamedRecoveryChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NamedRecoveryChecks.java
new file mode 100644
index 00000000..e36f5e19
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NamedRecoveryChecks.java
@@ -0,0 +1,398 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+import zone.moddev.mc.skysbuildingpiecesbop.LegacyItemRecovery;
+
+/** Exercises the early callback with the real previous release's exported native states. */
+final class NamedRecoveryChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private NamedRecoveryChecks() {
+ }
+
+ static void nativeBiomeDatafix(net.minecraft.server.MinecraftServer server) throws Exception {
+ JsonObject receipt = read("/fixtures/biomes/saved-bop-1.15.2.json");
+ byte[] bytes;
+ try (java.io.InputStream input = NamedRecoveryChecks.class.getResourceAsStream(
+ "/fixtures/biomes/saved-bop-1.15.2.nbt")) {
+ bytes = input.readAllBytes();
+ }
+ require(java.util.HexFormat.of().withUpperCase().formatHex(
+ java.security.MessageDigest.getInstance("SHA-256").digest(bytes))
+ .equals(receipt.get("source_chunk_sha256").getAsString()), "Saved biome fixture changed");
+ CompoundTag source = net.minecraft.nbt.NbtIo.readCompressed(new java.io.ByteArrayInputStream(bytes), net.minecraft.nbt.NbtAccounter.unlimitedHeap());
+ CompoundTag original = source.copy();
+ java.lang.reflect.Field snapshots = Class.forName(
+ "zone.moddev.mc.skysbuildingpiecesbop.NamedBopRecovery").getDeclaredField("biomeSnapshots");
+ snapshots.setAccessible(true);
+ Object previous = snapshots.get(null);
+ java.util.Map table = new java.util.LinkedHashMap<>();
+ receipt.getAsJsonObject("source_ids").entrySet().forEach(entry ->
+ table.put(Integer.parseInt(entry.getKey()), receipt.get("target").getAsString()));
+ java.util.NavigableMap> saved = new java.util.TreeMap<>();
+ saved.put(source.getIntOr("DataVersion", 0), table);
+ java.nio.file.Path temporary = java.nio.file.Files.createTempDirectory(
+ java.nio.file.Path.of("."), "named-biome-fixer-");
+ try (net.minecraft.world.level.chunk.storage.SimpleRegionStorage storage =
+ new net.minecraft.world.level.chunk.storage.SimpleRegionStorage(new net.minecraft.world.level.chunk.storage.RegionStorageInfo("test", net.minecraft.world.level.Level.OVERWORLD, "chunk"), temporary, server.getFixerUpper(), false, net.minecraft.util.datafix.DataFixTypes.CHUNK)) {
+ snapshots.set(null, saved);
+ CompoundTag fixed = BopProbeItems.upgradeChunk(storage, source);
+ ListTag sections = fixed.getListOrEmpty("sections");
+ require(sections.size() >= 16, "Native chunk conversion did not produce biome sections");
+ int cells = 0;
+ for (Tag entry : sections) {
+ CompoundTag section = (CompoundTag) entry;
+ int height = section.getByteOr("Y", (byte) 0);
+ if (height < 0 || height >= 16) {
+ continue;
+ }
+ ListTag palette = section.getCompoundOrEmpty("biomes").getListOrEmpty("palette");
+ require(palette.size() == 1 && palette.getStringOr(0, "").equals(receipt.get("target").getAsString()),
+ "Saved BOP biome was lost through native chunk fixing: section=" + height + " " + palette);
+ cells += 64;
+ }
+ require(cells == receipt.get("cells").getAsInt(), "Incomplete saved biome volume check");
+ require(source.equals(original), "Native recovery changed the independent source tag");
+ LOGGER.info("BOP_NATIVE_NAMED_BIOME_FIXER_PASS cells={} source=1.15.2 runtime={}",
+ cells, net.minecraft.SharedConstants.getCurrentVersion().name());
+ } finally {
+ snapshots.set(null, previous);
+ }
+ }
+
+ static void run() throws Exception {
+ JsonObject source = read("/fixtures/bop-1.14.4-native-catalogue.json");
+ JsonObject mappings = read("/assets/skysbuildingpiecesbop/catalogue/named_legacy.json");
+ JsonObject blocks = mappings.getAsJsonObject("blocks");
+ int states = 0;
+ int rejected = 0;
+
+ for (JsonElement value : source.getAsJsonArray("states")) {
+ JsonObject original = value.getAsJsonObject();
+ String name = original.get("id").getAsString();
+
+ if (!blocks.has(name)) {
+ continue;
+ }
+
+ CompoundTag saved = new CompoundTag();
+ CompoundTag properties = new CompoundTag();
+
+ saved.putString("Name", name);
+ original.getAsJsonObject("properties").entrySet()
+ .forEach(entry -> properties.putString(entry.getKey(), entry.getValue().getAsString()));
+ saved.put("Properties", properties);
+
+ CompoundTag root = palette(saved);
+ CompoundTag before = root.copy();
+ boolean doubleSlab = properties.getStringOr("type", "").equals("double");
+
+ if (doubleSlab && properties.getStringOr("waterlogged", "").equals("true")) {
+ try {
+ new LegacyItemRecovery().fixTagCompound(root);
+ throw new AssertionError("An impossible wet double slab lost its water silently");
+ } catch (IllegalStateException expected) {
+ require(root.equals(before), "Rejected recovery changed the source tag");
+ rejected++;
+ }
+ continue;
+ }
+
+ new LegacyItemRecovery().fixTagCompound(root);
+
+ CompoundTag result = state(root);
+ BlockState actual = BopProbeItems.readSavedBlockState(result);
+ String target = ForwardUpgradeChecks.currentTarget("blocks", name);
+ if (doubleSlab) {
+ target = switch (name) {
+ case "biomesoplenty:mud_brick_slab" -> "minecraft:mud_bricks";
+ case "biomesoplenty:cherry_slab" -> "minecraft:cherry_planks";
+ default -> "minecraft:birch_planks";
+ };
+ }
+
+ require(actual.getBlock() != Blocks.AIR && net.minecraftforge.registries.ForgeRegistries.BLOCKS.getKey(actual.getBlock()).toString().equals(target),
+ "Named block target changed: " + saved + " -> " + result);
+ if (!doubleSlab) {
+ for (String key : properties.keySet()) {
+ String expectedValue = properties.getStringOr(key, "");
+ if (name.equals("biomesoplenty:mud_brick_wall")
+ && java.util.Set.of("north", "south", "east", "west").contains(key)) {
+ expectedValue = expectedValue.equals("true") ? "low" : "none";
+ }
+ require(expectedValue.equals(result.getCompoundOrEmpty("Properties").getStringOr(key, "")),
+ "Named block property lost: " + key + " / " + saved + " -> " + result);
+ }
+ }
+
+ CompoundTag once = root.copy();
+
+ new LegacyItemRecovery().fixTagCompound(root);
+ require(root.equals(once), "Named block conversion repeated");
+ states++;
+ }
+
+ int items = 0;
+
+ for (JsonElement value : source.getAsJsonArray("items")) {
+ String id = value.getAsString();
+
+ if (!blocks.has(id) && !mappings.getAsJsonObject("items").has(id)) {
+ continue;
+ }
+
+ String target = ForwardUpgradeChecks.currentItemTarget(id);
+ CompoundTag stack = new CompoundTag();
+ CompoundTag tag = new CompoundTag();
+
+ stack.putString("id", id);
+ stack.putByte("Count", (byte) 37);
+ stack.putByte("Slot", (byte) 11);
+ tag.putString("custom_data", "Keep this exactly");
+ stack.put("tag", tag);
+
+ CompoundTag expected = stack.copy();
+
+ expected.putString("id", target);
+ new LegacyItemRecovery().fixTagCompound(stack);
+ require(stack.equals(expected), "Named stack lost its count, slot or custom data: " + id);
+ items++;
+ }
+
+ CompoundTag malformed = palette(new CompoundTag());
+ CompoundTag invalid = state(malformed);
+
+ invalid.putString("Name", "biomesoplenty:ethereal_stairs");
+ invalid.put("Properties", new CompoundTag());
+ invalid.getCompoundOrEmpty("Properties").putString("unsupported_shape", "true");
+
+ CompoundTag good = new CompoundTag();
+
+ good.putString("Name", "biomesoplenty:ethereal_planks");
+ malformed.getCompoundOrEmpty("Level").getListOrEmpty("Sections").getCompoundOrEmpty(0).getListOrEmpty("Palette").add(0, good);
+
+ CompoundTag before = malformed.copy();
+
+ try {
+ new LegacyItemRecovery().fixTagCompound(malformed);
+ throw new AssertionError("An unsupported named property was discarded");
+ } catch (IllegalStateException expected) {
+ require(malformed.equals(before), "Failed named conversion changed its source");
+ }
+
+ require(items == 58 && states > 300 && rejected > 0, "Named coverage is incomplete: items=" + items + " states=" + states + " rejected=" + rejected);
+ biomeSnapshotControls();
+ nativeBiomeDatafix(net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer());
+ removedRoots();
+ LOGGER.info("BOP_NAMED_RECOVERY_PASS states=" + states + " items=" + items
+ + " invalid_wet_double_refusals=" + rejected + " properties=true counts=true atomic=true");
+ }
+
+ private static void removedRoots() throws Exception {
+ JsonObject catalogue = read("/fixtures/bop-1.17.1-native-catalogue.json");
+ JsonObject mappings = read("/assets/skysbuildingpiecesbop/catalogue/named_legacy.json");
+ int checked = 0;
+ for (JsonElement entry : catalogue.getAsJsonArray("blocks")) {
+ JsonObject block = entry.getAsJsonObject();
+ String source = block.get("id").getAsString();
+ if (!source.equals("biomesoplenty:mangrove_root") && !source.equals("biomesoplenty:tree_roots")
+ && !source.equals("biomesoplenty:tree_roots_stem")) {
+ continue;
+ }
+ for (JsonElement variant : block.getAsJsonArray("states")) {
+ CompoundTag state = new CompoundTag();
+ CompoundTag properties = new CompoundTag();
+ state.putString("Name", source);
+ variant.getAsJsonObject().entrySet().forEach(property ->
+ properties.putString(property.getKey(), property.getValue().getAsString()));
+ state.put("Properties", properties);
+ CompoundTag root = palette(state);
+ new LegacyItemRecovery().fixTagCompound(root);
+ CompoundTag result = state(root);
+ require(result.getStringOr("Name", "").equals(mappings.getAsJsonObject("blocks").get(source).getAsString()),
+ "Removed root replacement changed: " + source);
+ for (String property : properties.keySet()) {
+ require(properties.getStringOr(property, "").equals(result.getCompoundOrEmpty("Properties").getStringOr(property, "")),
+ "A removed root lost " + property + ": " + result);
+ }
+ CompoundTag once = root.copy();
+ new LegacyItemRecovery().fixTagCompound(root);
+ require(root.equals(once), "Root recovery repeated");
+ checked++;
+ }
+ CompoundTag stack = new CompoundTag();
+ stack.putString("id", source);
+ stack.putByte("Count", (byte) 37);
+ CompoundTag custom = new CompoundTag();
+ custom.putString("Keep", "Stored root data");
+ stack.put("tag", custom);
+ new LegacyItemRecovery().fixTagCompound(stack);
+ String target = source.equals("biomesoplenty:tree_roots_stem") ? "minecraft:weeping_vines"
+ : mappings.getAsJsonObject("blocks").get(source).getAsString();
+ require(stack.getStringOr("id", "").equals(target) && stack.getByteOr("Count", (byte) 0) == 37
+ && stack.getCompoundOrEmpty("tag").equals(custom), "A removed root item lost its data");
+ require(!BopProbeItems.read(stack).isEmpty(), "A root became an itemless block");
+ }
+ require(checked == 31, "Incomplete removed-root state coverage: " + checked);
+ net.minecraft.server.level.ServerLevel world = net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer()
+ .getLevel(net.minecraft.world.level.Level.OVERWORLD);
+ net.minecraft.core.BlockPos position = new net.minecraft.core.BlockPos(112, 180, 112);
+ for (boolean water : new boolean[] {false, true}) {
+ world.setBlock(position.below(), Blocks.ROOTED_DIRT.defaultBlockState(), 18);
+ BlockState lower = Blocks.SMALL_DRIPLEAF.defaultBlockState()
+ .setValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.WATERLOGGED, water);
+ BlockState upper = lower.setValue(net.minecraft.world.level.block.DoublePlantBlock.HALF,
+ net.minecraft.world.level.block.state.properties.DoubleBlockHalf.UPPER);
+ world.setBlock(position, lower, 18);
+ world.setBlock(position.above(), upper, 18);
+ require(lower.canSurvive(world, position) && upper.canSurvive(world, position.above()),
+ "A recovered mangrove plant lost its support, water=" + water);
+ }
+ world.setBlock(position.above(), Blocks.OAK_LOG.defaultBlockState(), 18);
+ world.setBlock(position, Blocks.WEEPING_VINES_PLANT.defaultBlockState(), 18);
+ world.setBlock(position.below(), Blocks.WEEPING_VINES.defaultBlockState(), 18);
+ require(world.getBlockState(position).canSurvive(world, position)
+ && world.getBlockState(position.below()).canSurvive(world, position.below()),
+ "A recovered root strand lost its support");
+ LOGGER.info("BOP_REMOVED_ROOTS_PASS states=31 item_units=111 water=true support=true idempotent=true");
+ }
+
+ private static void biomeSnapshotControls() throws Exception {
+ Class> recovery = Class.forName("zone.moddev.mc.skysbuildingpiecesbop.NamedBopRecovery");
+ java.lang.reflect.Field snapshots = recovery.getDeclaredField("biomeSnapshots");
+
+ snapshots.setAccessible(true);
+
+ Object savedSnapshots = snapshots.get(null);
+ java.util.NavigableMap> testSnapshots = new java.util.TreeMap<>();
+
+ testSnapshots.put(1976, java.util.Collections.singletonMap(600, "biomesoplenty:undergrowth"));
+ try {
+ snapshots.set(null, testSnapshots);
+
+ CompoundTag source = biomeChunk(1976, 256);
+ CompoundTag original = source.copy();
+
+ new LegacyItemRecovery().fixTagCompound(source);
+
+ net.minecraft.world.level.biome.Biome target = net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer().registryAccess()
+ .lookupOrThrow(net.minecraft.core.registries.Registries.BIOME)
+ .getValue(net.minecraft.resources.Identifier.parse("biomesoplenty:undergrowth"));
+ int targetId = net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer().registryAccess()
+ .lookupOrThrow(net.minecraft.core.registries.Registries.BIOME).getId(target);
+
+ require(source.getCompoundOrEmpty("Level").getIntArray("Biomes").orElseThrow()[0] == targetId,
+ "Saved biome identity was not resolved through the current registry");
+ require(original.getCompoundOrEmpty("Level").getIntArray("Biomes").orElseThrow()[0] == 600,
+ "Biome recovery changed an independent source copy");
+
+ CompoundTag once = source.copy();
+
+ new LegacyItemRecovery().fixTagCompound(source);
+ require(source.equals(once), "A biome table was applied twice before vanilla data fixing");
+
+ for (int version : new int[] { 1343, net.minecraft.SharedConstants.getCurrentVersion().dataVersion().version() }) {
+ CompoundTag excluded = biomeChunk(version, 256);
+ CompoundTag before = excluded.copy();
+
+ new LegacyItemRecovery().fixTagCompound(excluded);
+ require(excluded.equals(before), "Named recovery changed numeric or already-current biome data");
+ }
+
+ CompoundTag malformed = biomeChunk(1976, 3);
+ CompoundTag before = malformed.copy();
+
+ try {
+ new LegacyItemRecovery().fixTagCompound(malformed);
+ throw new AssertionError("Malformed biome storage was accepted");
+ } catch (IllegalStateException expected) {
+ require(malformed.equals(before), "Rejected biome storage changed its source");
+ }
+ } finally {
+ snapshots.set(null, savedSnapshots);
+ }
+
+ java.lang.reflect.Method resolve = recovery.getDeclaredMethod("resolveBiomes", CompoundTag.class);
+
+ resolve.setAccessible(true);
+ for (boolean duplicateName : new boolean[] { false, true }) {
+ CompoundTag registry = new CompoundTag();
+ ListTag identities = new ListTag();
+
+ for (int index = 0; index < 2; index++) {
+ CompoundTag entry = new CompoundTag();
+
+ entry.putString("K", duplicateName ? "minecraft:plains" : "minecraft:biome_" + index);
+ entry.putInt("V", duplicateName ? index : 42);
+ identities.add(entry);
+ }
+ registry.put("ids", identities);
+ try {
+ resolve.invoke(null, registry);
+ throw new AssertionError("Ambiguous saved biome identities were accepted");
+ } catch (java.lang.reflect.InvocationTargetException expected) {
+ require(expected.getCause() instanceof IllegalStateException,
+ "Malformed registry failed for an unrelated reason");
+ }
+ }
+ LOGGER.info("BOP_NAMED_BIOME_CONTROLS_PASS scopes=2 duplicate_refusals=2 atomic=true idempotent=true");
+ }
+
+ private static CompoundTag biomeChunk(int version, int cells) {
+ CompoundTag root = new CompoundTag();
+ CompoundTag level = new CompoundTag();
+ int[] biomes = new int[cells];
+
+ java.util.Arrays.fill(biomes, 600);
+ root.putInt("DataVersion", version);
+ level.putIntArray("Biomes", biomes);
+ root.put("Level", level);
+ return root;
+ }
+
+ private static CompoundTag palette(CompoundTag saved) {
+ CompoundTag root = new CompoundTag();
+ CompoundTag level = new CompoundTag();
+ CompoundTag section = new CompoundTag();
+ ListTag sections = new ListTag();
+ ListTag states = new ListTag();
+
+ states.add(saved);
+ section.put("Palette", states);
+ sections.add(section);
+ level.put("Sections", sections);
+ root.put("Level", level);
+ return root;
+ }
+
+ private static CompoundTag state(CompoundTag root) {
+ return root.getCompoundOrEmpty("Level").getListOrEmpty("Sections").getCompoundOrEmpty(0).getListOrEmpty("Palette")
+ .getCompoundOrEmpty(0);
+ }
+
+ private static JsonObject read(String resource) throws Exception {
+ Class> owner = resource.startsWith("/assets/")
+ ? zone.moddev.mc.skysbuildingpiecesbop.RetiredRocks.class : NamedRecoveryChecks.class;
+ try (InputStreamReader reader = new InputStreamReader(java.util.Objects.requireNonNull(
+ owner.getResourceAsStream(resource), "Missing verification resource: " + resource),
+ StandardCharsets.UTF_8)) {
+ return new Gson().fromJson(reader, JsonObject.class);
+ }
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeBootstrap.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeBootstrap.java
new file mode 100644
index 00000000..93740000
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeBootstrap.java
@@ -0,0 +1,208 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import net.minecraft.world.item.ItemStack;
+import net.minecraftforge.common.MinecraftForge;
+import net.minecraftforge.fml.ModList;
+import net.minecraftforge.fml.common.Mod;
+import net.minecraftforge.event.server.ServerStartedEvent;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.level.Level;
+import zone.moddev.mc.skysbuildingpieces.probe.BopRecipeAudit;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Catalogue;
+import zone.moddev.mc.skysbuildingpieces.content.Pieces;
+import zone.moddev.mc.skysbuildingpiecesbop.RetiredRocks;
+
+/** A short native startup gate before the complete gameplay and upgrade suites. */
+@Mod("buildingpiecesbopchecks")
+public final class NativeBootstrap {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private net.minecraft.server.MinecraftServer pendingServer;
+ private int startupTicks;
+ private BopRecipeAudit recipes;
+ private MinecraftServer recipeServer;
+ private long nextRecipeBatch;
+ private MinecraftServer biomeAuditServer;
+ private int biomeAuditSaveTicks;
+
+ public NativeBootstrap(net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext context) {
+ zone.moddev.mc.skysbuildingpieces.probe.PersistenceRegistrationChecks.register(
+ context.getModBusGroup());
+ ServerStartedEvent.BUS.addListener(this::started);
+ net.minecraftforge.event.TickEvent.ServerTickEvent.Post.BUS.addListener(this::tick);
+ net.minecraftforge.fml.event.lifecycle.FMLLoadCompleteEvent.getBus(context.getModBusGroup())
+ .addListener(BopPersistenceOrderChecks::ready);
+ if (net.minecraftforge.fml.loading.FMLEnvironment.dist == net.minecraftforge.api.distmarker.Dist.CLIENT) {
+ new zone.moddev.mc.skysbuildingpieces.probe.ClientProbe().init();
+ BasketClientChecks.register();
+ BasketGuiClientChecks.register();
+ BasketDisconnectClientChecks.register();
+ }
+ }
+
+ private void started(ServerStartedEvent event) {
+ try {
+ if (NativeNamedForwardChecks.applies(new java.io.File("forward-fixture.json"))) {
+ pendingServer = event.getServer();
+ NativeNamedForwardChecks.prepare(pendingServer.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ return;
+ }
+ audit(event.getServer());
+ } catch (Exception failure) {
+ LOGGER.error("Cannot prepare the native BOP fixture", failure);
+ event.getServer().halt(false);
+ }
+ }
+
+ private void tick(net.minecraftforge.event.TickEvent.ServerTickEvent.Post event) {
+ if (event.server() == biomeAuditServer && --biomeAuditSaveTicks == 0) {
+ biomeAuditServer.halt(false);
+ biomeAuditServer = null;
+ return;
+ }
+ if (recipeServer != null && System.nanoTime() >= nextRecipeBatch) {
+ finishRecipeBatch();
+ return;
+ }
+ if (pendingServer == null || ++startupTicks < 20) {
+ return;
+ }
+ if (!NativeNamedForwardChecks.ready(pendingServer.getLevel(net.minecraft.world.level.Level.OVERWORLD))) {
+ if (startupTicks > 1200) {
+ LOGGER.error("The BOP fixture never finished loading its chunks and entities");
+ pendingServer.halt(false);
+ pendingServer = null;
+ }
+ return;
+ }
+ net.minecraft.server.MinecraftServer server = pendingServer;
+ pendingServer = null;
+ audit(server);
+ }
+
+ private void audit(net.minecraft.server.MinecraftServer server) {
+ boolean client = System.getProperty("skysbuildingpieces.integrationPhase", "").startsWith("client");
+ boolean success = false;
+
+ try {
+ RegistrySnapshot.write();
+ if (Boolean.getBoolean("skysbuildingpieces.bopBiomeFixerOnly")) {
+ NamedRecoveryChecks.nativeBiomeDatafix(server);
+ return;
+ }
+ if (Boolean.getBoolean("skysbuildingpieces.bopRegistryOnly")) {
+ LOGGER.info("BUILDING_PIECES_BOP_REGISTRY_ONLY_PASS");
+ return;
+ }
+ WallRecoveryChecks.run(server);
+ NamedRecoveryChecks.run();
+ NativeBopBiomeChecks.run(server);
+ require(ModList.isLoaded("skysbuildingpieces"), "The core is missing");
+ require(ModList.isLoaded("skysbuildingpiecesbop"), "The add-on is missing");
+ require(ModList.isLoaded("biomesoplenty"), "BOP is missing");
+
+ Catalogue.Module module = Catalogue.INSTANCE.modules.get("biomesoplenty");
+
+ require(module != null && module.materials.size() == 33 && module.palettes.size() == 66,
+ "Fixed BOP catalogue changed");
+
+ for (Catalogue.MaterialDef material : module.materials.values()) {
+ require(Pieces.nativeState(material, "full") != null, "No full material for " + material.id);
+ }
+ for (int variant = 0; variant < RetiredRocks.NAMES.length; variant++) {
+ ItemStack result = Pieces.stack(RetiredRocks.state(variant), 3);
+
+ require(result != null && result.getItem() == RetiredRocks.ITEMS[variant] && result.getCount() == 3,
+ "Retired full-block inventory identity changed: " + variant);
+ }
+
+ java.io.File manifest = new java.io.File("forward-fixture.json");
+
+ if (manifest.isFile()) {
+ if (!NativeNamedForwardChecks.applies(manifest)) {
+ NativeSavedBiomeChecks.prepare(server.getLevel(net.minecraft.world.level.Level.OVERWORLD),
+ new java.util.LinkedHashSet<>());
+ NativeSavedBiomeChecks.verify(server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ }
+ if (NativeNamedForwardChecks.applies(manifest)) {
+ NativeNamedForwardChecks.run(server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ } else if (Boolean.getBoolean("skysbuildingpieces.bopBiomeOnly")) {
+ ForwardUpgradeChecks.biomesOnly(server, manifest);
+ } else {
+ ForwardUpgradeChecks.run(server, manifest);
+ }
+ NativeNamedForwardChecks.checkNativeBlockRename(server.getLevel(Level.OVERWORLD));
+ } else {
+ if (Boolean.getBoolean("skysbuildingpieces.bopInstalledPolicy")) {
+ zone.moddev.mc.skysbuildingpieces.probe.BopCompatibilityChecks
+ .run(server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ }
+ BasketChecks.run(server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ RetiredItemChecks
+ .run(server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ PlantRecoveryChecks.run();
+ DecorationChecks.run(server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ MaterialChecks.run(server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ zone.moddev.mc.skysbuildingpieces.probe.BopMigrationChecks
+ .run(server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ if (!client) {
+ recipes = new BopRecipeAudit(server.getLevel(Level.OVERWORLD));
+ recipeServer = server;
+ nextRecipeBatch = System.nanoTime();
+ return;
+ }
+ }
+
+ BopPersistenceOrderChecks.verify(server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ LOGGER.info("BUILDING_PIECES_BOP_NATIVE_BOOTSTRAP_PASS materials=33 palettes=66 retired_items=7");
+ success = true;
+
+ if (client) {
+ zone.moddev.mc.skysbuildingpieces.probe.PresentationChecks.connectedGlass(
+ server.getLevel(net.minecraft.world.level.Level.OVERWORLD));
+ zone.moddev.mc.skysbuildingpieces.probe.ClientStatus.complete = true;
+ }
+ } catch (Throwable failure) {
+ LOGGER.error("Native BOP verification failed", failure);
+ zone.moddev.mc.skysbuildingpieces.probe.ClientStatus.failure = failure;
+ if (client && net.minecraftforge.fml.loading.FMLEnvironment.dist == net.minecraftforge.api.distmarker.Dist.CLIENT) {
+ net.minecraft.client.Minecraft.getInstance().delayCrash(
+ net.minecraft.CrashReport.forThrowable(failure, "BOP client verification failed"));
+ }
+ } finally {
+ if (!client && success && Boolean.getBoolean("skysbuildingpieces.bopBiomeOnly")) {
+ // Bulk biome reads queue chunk work. Let normal server ticks drain it before saving at shutdown.
+ biomeAuditServer = server;
+ biomeAuditSaveTicks = 40;
+ } else if ((!client || !success) && recipeServer == null) {
+ server.halt(false);
+ }
+ }
+ }
+
+ private void finishRecipeBatch() {
+ MinecraftServer server = recipeServer;
+ try {
+ if (!recipes.advance()) {
+ nextRecipeBatch = System.nanoTime() + 75_000_000L;
+ return;
+ }
+ // Resource reload can interrupt an integrated client's login.
+ zone.moddev.mc.skysbuildingpieces.probe.RecipeBookChecks.run(server.getLevel(Level.OVERWORLD));
+ BopPersistenceOrderChecks.verify(server.getLevel(Level.OVERWORLD));
+ LOGGER.info("BUILDING_PIECES_BOP_NATIVE_BOOTSTRAP_PASS materials=33 palettes=66 retired_items=7");
+ } catch (Throwable failure) {
+ LOGGER.error("Native BOP recipe verification failed", failure);
+ }
+ recipes = null;
+ recipeServer = null;
+ server.halt(false);
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeBopBiomeChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeBopBiomeChecks.java
new file mode 100644
index 00000000..4265b2af
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeBopBiomeChecks.java
@@ -0,0 +1,59 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import zone.moddev.mc.skysbuildingpieces.legacy.HistoricalBiomes;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.resources.Identifier;
+import net.minecraft.core.Registry;
+
+/** A pre-flattening BOP number must resolve in the active world's copied biome registry. */
+public final class NativeBopBiomeChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private static void invoke(String name, CompoundTag value) throws Exception {
+ java.lang.reflect.Method method = HistoricalBiomes.class.getDeclaredMethod(name, CompoundTag.class);
+ method.setAccessible(true);
+ method.invoke(null, value);
+ }
+
+ public static void run(MinecraftServer server) throws Exception {
+ java.lang.reflect.Field active = HistoricalBiomes.class.getDeclaredField("active");
+ active.setAccessible(true);
+ Object original = active.get(null);
+ try {
+ CompoundTag entry = new CompoundTag();
+ entry.putString("K", "biomesoplenty:bog");
+ entry.putInt("V", 200);
+ ListTag entries = new ListTag();
+ entries.add(entry);
+ CompoundTag biomes = new CompoundTag();
+ biomes.put("ids", entries);
+ CompoundTag registries = new CompoundTag();
+ registries.put("minecraft:biomes", biomes);
+ CompoundTag snapshot = new CompoundTag();
+ snapshot.put("Registries", registries);
+ invoke("begin", snapshot);
+ CompoundTag root = new CompoundTag();
+ root.putInt("DataVersion", 1343);
+ CompoundTag level = new CompoundTag();
+ byte[] cells = new byte[256];
+ java.util.Arrays.fill(cells, (byte) 200);
+ level.putByteArray("Biomes", cells);
+ root.put("Level", level);
+ invoke("prepare", root);
+ invoke("finish", root);
+ Registry current = server.registryAccess()
+ .lookupOrThrow(net.minecraft.core.registries.Registries.BIOME);
+ int expected = current.getId(current.getValue(Identifier.parse("biomesoplenty:wetland")));
+ if (expected < 0 || root.getCompoundOrEmpty("Level").getIntArray("Biomes").orElseThrow()[0] != expected) {
+ throw new AssertionError("Pre-flattening BOP biome was not resolved in the active registry");
+ }
+ LOGGER.info("BOP_NATIVE_NUMERIC_BIOME_PASS cells=256 active_registry=true");
+ } finally {
+ active.set(null, original);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeNamedForwardChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeNamedForwardChecks.java
new file mode 100644
index 00000000..295ddfed
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeNamedForwardChecks.java
@@ -0,0 +1,436 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.UUID;
+import net.minecraft.core.BlockPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.NbtUtils;
+import net.minecraft.nbt.Tag;
+import net.minecraft.nbt.TagParser;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.world.level.block.entity.ChestBlockEntity;
+import zone.moddev.mc.skysbuildingpieces.legacy.PiecesWorldState;
+
+/** Check a world generated by the exact previous release, including asynchronous entity storage. */
+final class NativeNamedForwardChecks {
+ private static final Set CHUNKS = new LinkedHashSet<>();
+ private static final Set ENTITIES = new LinkedHashSet<>();
+ private static JsonObject fixture;
+
+ private NativeNamedForwardChecks() {
+ }
+
+ static boolean applies(File file) throws Exception {
+ if (!file.isFile()) {
+ return false;
+ }
+ try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) {
+ JsonObject saved = new JsonParser().parse(reader).getAsJsonObject();
+ if (saved.has("kind") && saved.get("kind").getAsString().equals("named")) {
+ fixture = saved;
+ return true;
+ }
+ return false;
+ }
+ }
+
+ static void prepare(ServerLevel world) throws Exception {
+ CHUNKS.clear();
+ ENTITIES.clear();
+ NativeSavedBiomeChecks.prepare(world, CHUNKS);
+ Set deferred = NativeSavedBiomeChecks.deferredChunks();
+ for (String list : new String[] { "states", "containers", "entities" }) {
+ for (JsonElement value : fixture.getAsJsonArray(list)) {
+ JsonObject saved = value.getAsJsonObject();
+ ChunkPos chunk = ChunkPos.containing(position(saved));
+ if (deferred.contains(chunk)) {
+ require(list.equals("states"), "A deferred biome chunk contains a saved inventory or entity");
+ continue;
+ }
+ CHUNKS.add(chunk);
+ if (list.equals("entities")) {
+ ENTITIES.add(UUID.fromString(saved.get("uuid").getAsString()));
+ }
+ }
+ }
+ for (ChunkPos chunk : CHUNKS) {
+ world.setChunkForced(chunk.x(), chunk.z(), true);
+ world.getChunk(chunk.x(), chunk.z());
+ }
+ }
+
+ static boolean ready(ServerLevel world) {
+ for (ChunkPos chunk : CHUNKS) {
+ if (!world.areEntitiesLoaded(chunk.pack())
+ || !world.getChunkSource().isPositionTicking(chunk.pack())) {
+ return false;
+ }
+ }
+ return ENTITIES.stream().allMatch(identity -> world.getEntity(identity) != null);
+ }
+
+ static void run(ServerLevel world) throws Exception {
+ NativeSavedBiomeChecks.verify(world);
+ int states = 0;
+ int crystals = 0;
+ Set deferred = NativeSavedBiomeChecks.deferredChunks();
+ for (JsonElement value : fixture.getAsJsonArray("states")) {
+ JsonObject saved = value.getAsJsonObject();
+ if (deferred.contains(ChunkPos.containing(position(saved)))) {
+ require(world.getChunkSource().getChunkNow(position(saved).getX() >> 4,
+ position(saved).getZ() >> 4) == null, "A deferred source chunk was loaded early");
+ continue;
+ }
+ CompoundTag expected = TagParser.parseCompoundFully(saved.get("state").getAsString());
+ String source = expected.getStringOr("Name", "");
+ if (source.equals("biomesoplenty:rose_quartz_cluster") || source.equals("biomesoplenty:rose_quartz_block")) {
+ crystals++;
+ } else if (source.equals("biomesoplenty:mangrove_root")) {
+ expected.putString("Name", "minecraft:small_dripleaf");
+ expected.getCompoundOrEmpty("Properties").putString("facing", "north");
+ } else if (source.equals("biomesoplenty:tree_roots")) {
+ expected.putString("Name", "minecraft:weeping_vines");
+ } else if (source.equals("biomesoplenty:tree_roots_stem")) {
+ expected.putString("Name", "minecraft:weeping_vines_plant");
+ } else if (source.equals("biomesoplenty:nether_crystal")) {
+ CompoundTag properties = expected.getCompoundOrEmpty("Properties");
+ String face = properties.getStringOr("face", "");
+ String direction = face.equals("floor") ? "up"
+ : face.equals("ceiling") ? "down" : properties.getStringOr("facing", "");
+ CompoundTag replacement = new CompoundTag();
+ replacement.putString("facing", direction);
+ replacement.putString("waterlogged", "false");
+ expected.putString("Name", "biomesoplenty:rose_quartz_cluster");
+ expected.put("Properties", replacement);
+ crystals++;
+ } else if (source.equals("biomesoplenty:nether_crystal_block")) {
+ expected.putString("Name", "biomesoplenty:rose_quartz_block");
+ crystals++;
+ } else if (source.equals("minecraft:grass")) {
+ expected.putString("Name", "minecraft:short_grass");
+ } else if (source.equals("minecraft:grass_path")) {
+ expected.putString("Name", "minecraft:dirt_path");
+ } else if (source.equals("biomesoplenty:rooted_sand")) {
+ expected.putString("Name", "minecraft:rooted_dirt");
+ } else if (java.util.Set.of("biomesoplenty:mud", "biomesoplenty:mud_bricks",
+ "biomesoplenty:mud_brick_slab", "biomesoplenty:mud_brick_stairs",
+ "biomesoplenty:mud_brick_wall").contains(source)) {
+ expected.putString("Name", source.replace("biomesoplenty:", "minecraft:"));
+ if (source.equals("biomesoplenty:mud_brick_slab")
+ && expected.getCompoundOrEmpty("Properties").getStringOr("type", "").equals("double")) {
+ expected.putString("Name", "minecraft:mud_bricks");
+ expected.remove("Properties");
+ }
+ } else {
+ String replacement = ForwardUpgradeChecks.currentTarget("blocks", source);
+ if (replacement.equals("minecraft:cherry_slab")
+ && expected.getCompoundOrEmpty("Properties").getStringOr("type", "").equals("double")) {
+ expected.putString("Name", "minecraft:cherry_planks");
+ expected.remove("Properties");
+ } else {
+ expected.putString("Name", replacement);
+ }
+ }
+ net.minecraft.world.level.block.state.BlockState decoded = BopProbeItems.readSavedBlockState(expected);
+ if (source.endsWith("_maple_leaf_pile")) {
+ var segmentAmount = decoded.getBlock().getStateDefinition().getProperty("segment_amount");
+ require(segmentAmount != null, "A maple leaf pile must become leaf litter");
+ decoded = setExpectedProperty(decoded, segmentAmount, "4");
+ expected = NbtUtils.writeBlockState(decoded);
+ } else if (!expected.getStringOr("Name", "").equals(source)
+ && ForwardUpgradeChecks.isRecentBlockRename(source)) {
+ expected = NbtUtils.writeBlockState(decoded);
+ }
+ if (decoded.getBlock() instanceof zone.moddev.mc.skysbuildingpieces.content.PieceBlock) {
+ // Old palette holders remain registered, but their native BOP
+ // equivalents are the accepted stored form after loading.
+ expected = NbtUtils.writeBlockState(
+ zone.moddev.mc.skysbuildingpieces.content.Pieces.canonicalState(decoded));
+ }
+ CompoundTag actual = NbtUtils.writeBlockState(world.getBlockState(position(saved)));
+ require(zone.moddev.mc.skysbuildingpieces.probe.legacy.LegacyTestScope.nativeStateTag(expected).equals(actual), "Saved block changed at " + position(saved) + ": " + actual
+ + ", expected " + expected);
+ states++;
+ }
+ require(crystals == 13, "Every native crystal attachment and full block must be exercised");
+ checkSigns(world);
+
+ int units = 0;
+ int baskets = 0;
+ for (JsonElement value : fixture.getAsJsonArray("containers")) {
+ JsonObject saved = value.getAsJsonObject();
+ require(world.getBlockEntity(position(saved)) instanceof ChestBlockEntity, "Saved chest is missing");
+ ChestBlockEntity chest = (ChestBlockEntity) world.getBlockEntity(position(saved));
+ for (JsonElement item : saved.getAsJsonArray("items")) {
+ JsonObject entry = item.getAsJsonObject();
+ ItemStack actual = chest.getItem(entry.get("slot").getAsInt());
+ ItemStack expected = expectedItem(entry.get("stack").getAsString());
+ require(ItemStack.matches(expected, actual), "Saved inventory data changed: " + entry + ", actual "
+ + BopProbeItems.save(actual));
+ units += actual.getCount();
+ if (actual.getItem() == zone.moddev.mc.skysbuildingpiecesbop.basket.Baskets.ITEM) {
+ require(BopProbeItems.custom(actual).getListOrEmpty("Items").equals(BopProbeItems.custom(expected).getListOrEmpty("Items")), "Saved basket lost a slot");
+ baskets++;
+ }
+ }
+ }
+ require(baskets == (fixture.has("original_baskets") ? fixture.getAsJsonArray("original_baskets").size() + 1 : 1), "Every source basket must survive");
+ for (JsonElement value : fixture.getAsJsonArray("entities")) {
+ JsonObject saved = value.getAsJsonObject();
+ net.minecraft.world.entity.Entity entity = world.getEntity(UUID.fromString(saved.get("uuid").getAsString()));
+ ItemStack expected = expectedItem(saved.get("stack").getAsString());
+ require(entity instanceof ItemEntity && ItemStack.matches(expected, ((ItemEntity) entity).getItem()),
+ "Saved dropped item changed or disappeared");
+ units += expected.getCount();
+ }
+ UUID playerId = UUID.fromString(fixture.get("player").getAsString());
+ net.minecraft.server.level.ServerPlayer player = BopProbeItems.player(world,
+ new com.mojang.authlib.GameProfile(playerId, "PieceFixture"));
+ BopProbeItems.loadPlayer(player);
+ require(ItemStack.matches(expectedItem(fixture.get("player_shovel").getAsString()), player.getInventory().getItem(0)),
+ "Player tool lost damage, enchantments, name or custom data");
+ ItemStack nested = expectedItem(fixture.get("player_nested").getAsString());
+ require(ItemStack.matches(nested, player.getInventory().getItem(1)), "Nested player inventory changed");
+ require(ItemStack.matches(nested, player.getEnderChestInventory().getItem(0)), "Ender inventory changed");
+ java.lang.reflect.Method save = null;
+ for (java.lang.reflect.Method method : net.minecraft.server.players.PlayerList.class.getDeclaredMethods()) {
+ if (java.lang.reflect.Modifier.isProtected(method.getModifiers()) && method.getReturnType() == void.class
+ && java.util.Arrays.equals(method.getParameterTypes(),
+ new Class>[] { net.minecraft.server.level.ServerPlayer.class })) {
+ require(save == null, "Ambiguous native player-save method");
+ save = method;
+ }
+ }
+ require(save != null, "Native player-save method is missing");
+ save.setAccessible(true);
+ save.invoke(world.getServer().getPlayerList(), player);
+ require(PiecesWorldState.get(world).saveTag(new CompoundTag()).equals(
+ TagParser.parseCompoundFully(fixture.get("world_state").getAsString())), "Saved schema or migration counters changed");
+ for (ChunkPos chunk : CHUNKS) {
+ world.setChunkForced(chunk.x(), chunk.z(), false);
+ }
+ ForwardUpgradeChecks.checkSavedRecoveryData(world);
+ if (!deferred.isEmpty()) {
+ java.nio.file.Files.writeString(java.nio.file.Path.of("delayed-biomes-first-pass.txt"),
+ "deferred_chunks=" + deferred.size() + "\n", StandardCharsets.UTF_8);
+ }
+ org.apache.logging.log4j.LogManager.getLogger().info("BUILDING_PIECES_BOP_FORWARD_UPGRADE_PASS named_states="
+ + states + " crystals=" + crystals + " item_units=" + units + " basket_slots=18 schema=1 counters=unchanged");
+ }
+
+ private static > net.minecraft.world.level.block.state.BlockState setExpectedProperty(
+ net.minecraft.world.level.block.state.BlockState state,
+ net.minecraft.world.level.block.state.properties.Property property, String value) {
+ return state.setValue(property, property.getValue(value).orElseThrow());
+ }
+
+ private static ItemStack expectedItem(String text) throws Exception {
+ CompoundTag saved = TagParser.parseCompoundFully(text);
+ rewriteExpectedItems(saved);
+ return BopProbeItems.read(saved);
+ }
+
+ private static void rewriteExpectedItems(Tag value) {
+ if (value instanceof CompoundTag) {
+ CompoundTag saved = (CompoundTag) value;
+ String id = saved.getStringOr("id", "");
+ if (saved.get("Count") instanceof net.minecraft.nbt.NumericTag
+ || saved.get("count") instanceof net.minecraft.nbt.NumericTag
+ || ForwardUpgradeChecks.isRecentBlockRename(id)
+ && Set.of("id", "components", "Slot").containsAll(saved.keySet())) {
+ if (id.equals("minecraft:grass")) {
+ saved.putString("id", "minecraft:short_grass");
+ } else if (id.equals("minecraft:grass_path")) {
+ saved.putString("id", "minecraft:dirt_path");
+ } else if (id.equals("biomesoplenty:rooted_sand")) {
+ saved.putString("id", "minecraft:rooted_dirt");
+ } else if (id.equals("biomesoplenty:mud_ball")) {
+ saved.putString("id", "minecraft:clay_ball");
+ } else if (id.equals("biomesoplenty:mud_brick")) {
+ saved.putString("id", "minecraft:brick");
+ } else if (java.util.Set.of("biomesoplenty:mud", "biomesoplenty:mud_bricks",
+ "biomesoplenty:mud_brick_slab", "biomesoplenty:mud_brick_stairs",
+ "biomesoplenty:mud_brick_wall").contains(id)) {
+ saved.putString("id", id.replace("biomesoplenty:", "minecraft:"));
+ } else if (id.equals("biomesoplenty:mangrove_root")) {
+ saved.putString("id", "minecraft:small_dripleaf");
+ } else if (id.equals("biomesoplenty:tree_roots") || id.equals("biomesoplenty:tree_roots_stem")) {
+ saved.putString("id", "minecraft:weeping_vines");
+ } else if (id.equals("biomesoplenty:nether_crystal_block")) {
+ saved.putString("id", "biomesoplenty:rose_quartz_block");
+ } else if (id.equals("biomesoplenty:nether_crystal")) {
+ saved.putString("id", "biomesoplenty:rose_quartz_cluster");
+ CompoundTag properties = saved.getCompoundOrEmpty("tag").getCompoundOrEmpty("BlockStateTag");
+ if (!properties.isEmpty()) {
+ String face = properties.getStringOr("face", "");
+ String direction = face.equals("floor") ? "up"
+ : face.equals("ceiling") ? "down" : properties.getStringOr("facing", "");
+ properties.remove("face");
+ properties.putString("facing", direction);
+ properties.putString("waterlogged", "false");
+ }
+ } else {
+ saved.putString("id", ForwardUpgradeChecks.currentItemTarget(id));
+ }
+ }
+ for (String key : saved.keySet()) {
+ rewriteExpectedItems(saved.get(key));
+ }
+ } else if (value instanceof net.minecraft.nbt.ListTag) {
+ for (Tag child : (net.minecraft.nbt.ListTag) value) {
+ rewriteExpectedItems(child);
+ }
+ }
+ }
+
+ private static BlockPos position(JsonObject value) {
+ return new BlockPos(value.get("x").getAsInt(), value.get("y").getAsInt(), value.get("z").getAsInt());
+ }
+
+ private static void checkSigns(ServerLevel world) throws Exception {
+ if (!fixture.has("signs")) {
+ return;
+ }
+ for (JsonElement entry : fixture.getAsJsonArray("signs")) {
+ JsonObject saved = entry.getAsJsonObject();
+ BlockPos position = position(saved);
+ CompoundTag original = TagParser.parseCompoundFully(saved.get("tag").getAsString());
+ require(world.getBlockEntity(position) instanceof net.minecraft.world.level.block.entity.SignBlockEntity,
+ "Saved cherry sign lost its block entity at " + position);
+ var sign = (net.minecraft.world.level.block.entity.SignBlockEntity) world.getBlockEntity(position);
+ require(sign.getType() == net.minecraft.world.level.block.entity.BlockEntityTypes.SIGN,
+ "Saved cherry sign still uses an incompatible block entity type at " + position);
+ var front = sign.getText(net.minecraft.world.level.block.entity.SignTextSlot.FRONT);
+ if ((original.get("front_text") instanceof net.minecraft.nbt.CompoundTag)) {
+ checkSignFace(world, front, original.getCompoundOrEmpty("front_text"), position);
+ checkSignFace(world, sign.getText(net.minecraft.world.level.block.entity.SignTextSlot.BACK), original.getCompoundOrEmpty("back_text"), position);
+ require(sign.isWaxed() == original.getBooleanOr("is_waxed", false), "Saved sign wax changed at " + position);
+ continue;
+ }
+ for (int line = 0; line < 4; line++) {
+ require(front.getMessages(false).get(line).equals(BopProbeItems.readSavedText(original.getStringOr("Text" + (line + 1), ""), world)), "Saved sign text changed at " + position);
+ require(front.getMessages(true).get(line).equals(BopProbeItems.readSavedText(original.getStringOr("FilteredText" + (line + 1), ""), world)), "Saved filtered sign text changed at " + position);
+ }
+ require(front.getColor().getName().equals(original.getStringOr("Color", ""))
+ && front.hasGlowingText() == original.getBooleanOr("GlowingText", false),
+ "Saved sign colour or glow changed at " + position);
+ }
+ }
+
+ static void checkNativeBlockRename(ServerLevel world) throws Exception {
+ var path = java.nio.file.Path.of("forward-block-renames.json");
+ if (!java.nio.file.Files.isRegularFile(path)) {
+ return;
+ }
+ JsonObject proof = JsonParser.parseString(java.nio.file.Files.readString(path)).getAsJsonObject();
+ require(proof.get("source").getAsString().equals("minecraft:grass")
+ && proof.get("target").getAsString().equals("minecraft:short_grass"), "Unexpected source rename");
+ var seen = new java.util.HashSet();
+ boolean oldHeight = proof.has("pre_height_change") && proof.get("pre_height_change").getAsBoolean();
+ int naturalChanges = 0;
+ java.util.Map adjacentFluids = new java.util.HashMap<>();
+ if (proof.has("adjacent_fluids")) {
+ for (var entry : proof.getAsJsonArray("adjacent_fluids")) {
+ JsonObject expected = entry.getAsJsonObject();
+ var xyz = expected.getAsJsonArray("position");
+ var position = new BlockPos(xyz.get(0).getAsInt(), xyz.get(1).getAsInt(), xyz.get(2).getAsInt());
+ require(adjacentFluids.put(position, expected) == null, "Duplicate source fluid position");
+ }
+ }
+ var fluidPath = java.nio.file.Path.of("forward-fluid-changes.json");
+ if (java.nio.file.Files.isRegularFile(fluidPath)) {
+ var fluidProof = JsonParser.parseString(java.nio.file.Files.readString(fluidPath)).getAsJsonObject();
+ require(fluidProof.get("source_world_sha256").equals(fixture.get("archive_sha256")),
+ "Fluid controls belong to a different source archive");
+ for (var entry : fluidProof.getAsJsonArray("positions")) {
+ var change = entry.getAsJsonObject();
+ var xyz = change.getAsJsonArray("position");
+ var position = new BlockPos(xyz.get(0).getAsInt(), xyz.get(1).getAsInt(), xyz.get(2).getAsInt());
+ require(adjacentFluids.putIfAbsent(position, change) == null, "Duplicate source fluid control");
+ }
+ }
+ for (var row : proof.getAsJsonArray("positions")) {
+ var xyz = row.getAsJsonArray();
+ var position = new BlockPos(xyz.get(0).getAsInt(), xyz.get(1).getAsInt(), xyz.get(2).getAsInt());
+ require(seen.add(position), "Duplicate source grass position at " + position);
+ var state = world.getBlockState(position);
+ if (!state.is(net.minecraft.world.level.block.Blocks.SHORT_GRASS)) {
+ var grass = net.minecraft.world.level.block.Blocks.SHORT_GRASS.defaultBlockState();
+ JsonObject fluid = adjacentFluids.get(position);
+ var blocks = net.minecraft.core.registries.BuiltInRegistries.BLOCK;
+ boolean matchesFluid = fluid != null
+ && fluid.get("target").getAsString().equals(blocks.getKey(state.getBlock()).toString())
+ && fluid.get("support").getAsString().equals(
+ blocks.getKey(world.getBlockState(position.below()).getBlock()).toString());
+ if (matchesFluid && fluid.has("source_lava")) {
+ var upstream = fluid.getAsJsonArray("source_lava");
+ var sourcePos = new BlockPos(upstream.get(0).getAsInt(), upstream.get(1).getAsInt(),
+ upstream.get(2).getAsInt());
+ var stateData = NbtUtils.writeBlockState(state);
+ matchesFluid = world.getBlockState(sourcePos).is(net.minecraft.world.level.block.Blocks.LAVA)
+ && stateData.getCompoundOrEmpty("properties").getStringOr("level", "")
+ .equals(fluid.get("level").getAsString());
+ }
+ require(matchesFluid || oldHeight && (state.is(net.minecraft.world.level.block.Blocks.WATER)
+ || state.isAir() && !grass.canSurvive(world, position)), "Saved short grass changed at " + position);
+ naturalChanges++;
+ }
+ }
+ require(seen.size() == proof.get("checked_positions").getAsInt(), "Incomplete vanilla grass rename check");
+ org.apache.logging.log4j.LogManager.getLogger().info(
+ "BUILDING_PIECES_NATIVE_BLOCK_RENAME_PASS source=minecraft:grass target=minecraft:short_grass positions={}",
+ seen.size());
+ if (proof.has("staged_states") && proof.get("staged_states").getAsInt() > 0) {
+ var saved = world.getServer().getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT)
+ .resolve("data/skysbuildingpieces_legacy_registry.dat");
+ var states = net.minecraft.nbt.NbtIo.readCompressed(saved, net.minecraft.nbt.NbtAccounter.unlimitedHeap())
+ .getListOrEmpty("staging");
+ int renamed = 0;
+ for (Tag row : states) {
+ String name = ((CompoundTag) row).getCompoundOrEmpty("state").getStringOr("Name", "");
+ require(!name.equals("minecraft:grass"), "A saved numeric staging state still uses the old grass name");
+ if (name.equals("minecraft:short_grass")) {
+ renamed++;
+ }
+ }
+ require(renamed >= proof.get("staged_states").getAsInt(), "A short grass staging state is missing");
+ }
+ if (naturalChanges > 0) {
+ org.apache.logging.log4j.LogManager.getLogger().info(
+ "BOP_NATIVE_GRASS_POST_PROCESSING_PASS fluids_or_unsupported={}", naturalChanges);
+ }
+ }
+
+ private static void checkSignFace(ServerLevel world, net.minecraft.world.level.block.entity.SignText actual,
+ CompoundTag saved, BlockPos position) {
+ var lines = saved.getListOrEmpty("messages");
+ var filtered = saved.getListOrEmpty("filtered_messages");
+ require(lines.size() == 4 && (filtered.isEmpty() || filtered.size() == 4), "Malformed source sign text");
+ for (int line = 0; line < 4; line++) {
+ var visible = BopProbeItems.readSavedText(lines.getStringOr(line, ""), world);
+ var filteredText = filtered.isEmpty() ? visible : BopProbeItems.readSavedText(filtered.getStringOr(line, ""), world);
+ require(actual.getMessages(false).get(line).equals(visible) && actual.getMessages(true).get(line).equals(filteredText),
+ "Saved sign text changed at " + position);
+ }
+ require(actual.getColor().getName().equals(saved.getStringOr("color", ""))
+ && actual.hasGlowingText() == saved.getBooleanOr("has_glowing_text", false),
+ "Saved sign colour or glow changed at " + position);
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeSavedBiomeChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeSavedBiomeChecks.java
new file mode 100644
index 00000000..b6e718b2
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/NativeSavedBiomeChecks.java
@@ -0,0 +1,236 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.io.ByteArrayInputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.Set;
+import java.util.zip.InflaterInputStream;
+import net.minecraft.core.Registry;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
+
+/** Checks the complete saved biome volume, independently of piece placement. */
+public final class NativeSavedBiomeChecks {
+ private NativeSavedBiomeChecks() {
+ }
+
+ private static JsonObject contract() throws Exception {
+ JsonObject data = new JsonParser().parse(Files.readString(Path.of("forward-biomes.json"))).getAsJsonObject();
+ JsonObject fixture = new JsonParser().parse(Files.readString(Path.of("forward-fixture.json"))).getAsJsonObject();
+ String format = data.get("format").getAsString();
+ require(format.equals("zlib-u8-quart-volume-v1") || format.equals("zlib-u8-quart-volume-v2"),
+ "Unknown biome contract");
+ if (format.equals("zlib-u8-quart-volume-v2")) {
+ require(data.get("min_quart_y").getAsInt() == -16 && data.get("quart_height").getAsInt() == 96,
+ "The native 1.18 world height changed");
+ }
+ require(data.get("source_world_sha256").equals(fixture.get("archive_sha256")), "Wrong source biome archive");
+ JsonElement sourceVersion = fixture.has("minecraft")
+ ? fixture.get("minecraft") : fixture.get("source_minecraft");
+ require(sourceVersion != null && data.get("source_minecraft").equals(sourceVersion),
+ "Wrong source biome version");
+ return data;
+ }
+
+ public static void prepare(ServerLevel world, Set tracked) throws Exception {
+ if (!Files.isRegularFile(Path.of("forward-biomes.json"))) return;
+ var registry = world.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.BIOME).asHolderIdMap();
+ int maximum = -1;
+ for (var biome : registry) {
+ maximum = Math.max(maximum, registry.getId(biome));
+ }
+ int expectedBits = 32 - Integer.numberOfLeadingZeros(maximum);
+ var strategy = net.minecraft.world.level.chunk.Strategy.createForBiomes(registry);
+ var configurationForSize = net.minecraft.world.level.chunk.Strategy.class
+ .getDeclaredMethod("getConfigurationForPaletteSize", int.class);
+ configurationForSize.setAccessible(true);
+ var configuration = (net.minecraft.world.level.chunk.Configuration) configurationForSize.invoke(strategy, 9);
+ // A saved local palette still needs four bits. Its runtime global palette needs the registry width.
+ require(configuration.bitsInStorage() == 4 && configuration.bitsInMemory() == expectedBits,
+ "Biome palette widths cannot retain the saved registry: entries=" + registry.size() + " max=" + maximum
+ + " storage=" + configuration.bitsInStorage() + " memory=" + configuration.bitsInMemory());
+ int actualBits = configuration.bitsInMemory();
+ org.apache.logging.log4j.LogManager.getLogger().info(
+ "BOP_NATIVE_BIOME_STORAGE_PASS entries={} max={} bits={}", registry.size(), maximum, actualBits);
+ Set deferred = deferredChunks();
+ // Loading the surrounding range would also pull in the chunks reserved for reload.
+ // The first delayed pass checks their raw conversion without asking the world for them.
+ if (!deferred.isEmpty()) return;
+ for (JsonElement value : contract().getAsJsonArray("chunks")) {
+ JsonObject row = value.getAsJsonObject();
+ ChunkPos position = new ChunkPos(row.get("x").getAsInt(), row.get("z").getAsInt());
+ if (!deferred.contains(position) && tracked.add(position)) {
+ world.setChunkForced(position.x(), position.z(), true);
+ world.getChunk(position.x(), position.z());
+ }
+ }
+ }
+
+ public static void verify(ServerLevel world) throws Exception {
+ if (!Files.isRegularFile(Path.of("forward-biomes.json"))) return;
+ JsonObject data = contract();
+ JsonObject names = data.getAsJsonObject("source_names");
+ boolean expandedHeight = data.get("format").getAsString().equals("zlib-u8-quart-volume-v2");
+ boolean numericBlocks = data.get("source_minecraft").getAsString().equals("1.12.2");
+ int minimumQuartY = expandedHeight ? data.get("min_quart_y").getAsInt() : 0;
+ int expectedCells = expandedHeight ? data.get("quart_height").getAsInt() * 16 : 1024;
+ var registry = world.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.BIOME);
+ Set deferred = deferredChunks();
+ long cells = 0;
+ long renamed = 0;
+ Path temporary = deferred.isEmpty() ? null : Files.createTempDirectory(Path.of("."), "deferred-biome-fixer-");
+ try (SimpleRegionStorage storage = temporary == null ? null
+ : new SimpleRegionStorage(new net.minecraft.world.level.chunk.storage.RegionStorageInfo("test", net.minecraft.world.level.Level.OVERWORLD, "chunk"), temporary, world.getServer().getFixerUpper(), false, net.minecraft.util.datafix.DataFixTypes.CHUNK)) {
+ for (JsonElement entry : data.getAsJsonArray("chunks")) {
+ JsonObject row = entry.getAsJsonObject();
+ int chunkX = row.get("x").getAsInt();
+ int chunkZ = row.get("z").getAsInt();
+ byte[] source;
+ try (InflaterInputStream input = new InflaterInputStream(new ByteArrayInputStream(
+ Base64.getDecoder().decode(row.get("cells").getAsString())))) {
+ source = input.readAllBytes();
+ }
+ require(source.length == expectedCells, "Incomplete saved biome height");
+ ChunkPos position = new ChunkPos(chunkX, chunkZ);
+ boolean unopened = !deferred.isEmpty()
+ && world.getChunkSource().getChunkNow(chunkX, chunkZ) == null;
+ if (deferred.contains(position)) {
+ require(unopened, "A deferred biome chunk was loaded before its conversion check: " + position);
+ }
+ CompoundTag converted = unopened ? convertUnopened(world, storage, position) : null;
+ var chunk = unopened ? null : world.getChunk(chunkX, chunkZ);
+ for (int cell = 0; cell < source.length; cell++) {
+ String oldName = names.get(Integer.toString(Byte.toUnsignedInt(source[cell]))).getAsString();
+ String priorTarget = data.getAsJsonObject("target_names").get(oldName).getAsString();
+ String expected = oldName.startsWith("biomesoplenty:") && !numericBlocks
+ ? ForwardUpgradeChecks.currentTarget(expandedHeight ? "section_biomes" : "biomes", oldName)
+ : priorTarget;
+ if (numericBlocks || expected.equals(oldName)) {
+ expected = ForwardUpgradeChecks.currentTarget("section_biomes", priorTarget);
+ }
+ String actual = unopened ? convertedBiome(converted, cell, minimumQuartY)
+ : registry.getKey(chunk.getNoiseBiome((chunkX << 2) + (cell & 3),
+ minimumQuartY + (cell >> 4), (chunkZ << 2) + (cell >> 2 & 3)).value()).toString();
+ require(actual.equals(expected),
+ "Native biome conversion at " + chunkX + "," + chunkZ + " cell=" + cell
+ + " expected=" + expected + " actual=" + actual);
+ cells++;
+ if (oldName.startsWith("minecraft:") && !oldName.equals(priorTarget)) {
+ renamed++;
+ }
+ }
+ }
+ }
+ require(cells == data.get("checked_cells").getAsLong(), "Incomplete biome audit");
+ require(renamed == data.get("native_renamed_cells").getAsLong(), "Native rename coverage changed");
+ org.apache.logging.log4j.LogManager.getLogger().info(
+ "BOP_SAVED_BIOME_VOLUME_PASS source={} cells={} renamed={}",
+ data.get("source_minecraft").getAsString(), cells, renamed);
+ if (!deferred.isEmpty()) {
+ org.apache.logging.log4j.LogManager.getLogger().info(
+ "BOP_DEFERRED_BIOME_DATAFIX_PASS chunks={} still_unloaded=true", deferred.size());
+ }
+ }
+
+ static Set deferredChunks() throws Exception {
+ Set deferred = new java.util.LinkedHashSet<>();
+ if (!Boolean.getBoolean("skysbuildingpieces.bopDelayedBiomes")
+ || Files.isRegularFile(Path.of("delayed-biomes-first-pass.txt"))) return deferred;
+ JsonObject fixture = new JsonParser().parse(Files.readString(Path.of("forward-fixture.json"))).getAsJsonObject();
+ if (fixture.has("kind") && fixture.get("kind").getAsString().equals("named")) {
+ if (fixture.has("source_biome_chunk_x") && fixture.has("source_biome_chunk_z")) {
+ deferred.add(new ChunkPos(fixture.get("source_biome_chunk_x").getAsInt(),
+ fixture.get("source_biome_chunk_z").getAsInt()));
+ } else {
+ for (JsonElement value : fixture.getAsJsonArray("states")) {
+ JsonObject row = value.getAsJsonObject();
+ if (row.has("role") && row.get("role").getAsString().equals("native source biome chunk")) {
+ deferred.add(new ChunkPos(row.get("x").getAsInt() >> 4, row.get("z").getAsInt() >> 4));
+ }
+ }
+ }
+ require(deferred.size() == 1, "The named fixture needs its isolated source-biome chunk");
+ return deferred;
+ }
+ JsonObject aliases;
+ try (var input = zone.moddev.mc.skysbuildingpiecesbop.RetiredRocks.class.getResourceAsStream(
+ "/assets/skysbuildingpiecesbop/catalogue/named_legacy.json")) {
+ require(input != null, "The add-on's biome mapping resource is missing");
+ aliases = new JsonParser().parse(new java.io.InputStreamReader(input, java.nio.charset.StandardCharsets.UTF_8))
+ .getAsJsonObject().getAsJsonObject("biomes");
+ }
+ int number = 0;
+ for (JsonElement entry : fixture.getAsJsonObject("manifest").getAsJsonArray("biomes")) {
+ JsonObject row = entry.getAsJsonObject();
+ if ((number++ & 1) != 0 || aliases.has(row.get("id").getAsString())) {
+ deferred.add(new ChunkPos(row.get("x").getAsInt() >> 4, row.get("z").getAsInt() >> 4));
+ }
+ }
+ return deferred;
+ }
+
+ private static CompoundTag convertUnopened(ServerLevel world, SimpleRegionStorage storage, ChunkPos position)
+ throws Exception {
+ require(world.getChunkSource().getChunkNow(position.x(), position.z()) == null,
+ "An unopened biome chunk was loaded before its conversion check: " + position);
+ Path root = world.getServer().getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT);
+ String relative = "region/r." + (position.x() >> 5) + "." + (position.z() >> 5) + ".mca";
+ Path region = root.resolve(relative);
+ if (!Files.isRegularFile(region)) {
+ region = root.resolve("dimensions/minecraft/overworld").resolve(relative);
+ }
+ byte[] bytes = Files.readAllBytes(region);
+ var header = java.nio.ByteBuffer.wrap(bytes);
+ int location = header.getInt(((position.x() & 31) + (position.z() & 31) * 32) * 4);
+ require(location != 0, "A deferred source chunk is missing");
+ int offset = (location >>> 8) * 4096;
+ int length = header.getInt(offset);
+ java.io.InputStream payload = new ByteArrayInputStream(bytes, offset + 5, length - 1);
+ if (bytes[offset + 4] == 2) payload = new InflaterInputStream(payload);
+ else if (bytes[offset + 4] == 1) payload = new java.util.zip.GZIPInputStream(payload);
+ else require(bytes[offset + 4] == 3, "Unknown deferred chunk compression");
+ CompoundTag source;
+ try (var input = new java.io.DataInputStream(payload)) {
+ source = net.minecraft.nbt.NbtIo.read(input);
+ }
+ CompoundTag chunk = (source.get("Level") instanceof net.minecraft.nbt.CompoundTag) ? source.getCompoundOrEmpty("Level") : source;
+ require(chunk.getIntOr("xPos", 0) == position.x() && chunk.getIntOr("zPos", 0) == position.z(),
+ "Wrong deferred source coordinates");
+ CompoundTag fixed = BopProbeItems.upgradeChunk(storage, source);
+ require(world.getChunkSource().getChunkNow(position.x(), position.z()) == null,
+ "The datafix check loaded a deferred biome chunk");
+ return fixed;
+ }
+
+ private static String convertedBiome(CompoundTag chunk, int cell, int minimumQuartY) {
+ int sectionY = (minimumQuartY >> 2) + (cell >> 6);
+ ListTag sections = chunk.getListOrEmpty("sections");
+ for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
+ CompoundTag section = sections.getCompoundOrEmpty(sectionIndex);
+ if (section.getByteOr("Y", (byte) 0) != sectionY) continue;
+ CompoundTag biomes = section.getCompoundOrEmpty("biomes");
+ ListTag palette = biomes.getListOrEmpty("palette");
+ require(!palette.isEmpty(), "Missing deferred biome palette");
+ if (palette.size() == 1) return palette.getStringOr(0, "");
+ int bits = 32 - Integer.numberOfLeadingZeros(palette.size() - 1);
+ int perLong = 64 / bits;
+ long number = biomes.getLongArray("data").orElseThrow()[(cell & 63) / perLong]
+ >>> ((cell & 63) % perLong * bits);
+ return palette.getString((int) (number & ((1L << bits) - 1))).orElseThrow();
+ }
+ throw new AssertionError("Missing deferred biome section " + sectionY);
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/PlantRecoveryChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/PlantRecoveryChecks.java
new file mode 100644
index 00000000..5f6b0361
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/PlantRecoveryChecks.java
@@ -0,0 +1,91 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import zone.moddev.mc.skysbuildingpiecesbop.LegacyItemRecovery;
+import zone.moddev.mc.skysbuildingpiecesbop.basket.BasketData;
+import zone.moddev.mc.skysbuildingpiecesbop.basket.Baskets;
+
+/** Old mod plants need vanilla's name and enchantment updates as well as a new ID. */
+final class PlantRecoveryChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private PlantRecoveryChecks() {
+ }
+
+ static void run() {
+ for (String source : new String[] { "biomesoplenty:sapling_0", "minecraft:sapling" }) {
+ CompoundTag basket = BopProbeItems.legacy(new ItemStack(Baskets.ITEM));
+ CompoundTag tag = new CompoundTag();
+ CompoundTag plant = new CompoundTag();
+
+ plant.putString("id", source);
+ plant.putByte("Count", (byte) 23);
+ plant.putByte("Slot", (byte) 4);
+ plant.putShort("Damage", (short) 0);
+
+ CompoundTag custom = new CompoundTag();
+ CompoundTag display = new CompoundTag();
+
+ display.putString("Name", "Saved tree");
+ custom.put("display", display);
+ custom.putString("unrelated", "Keep this data");
+ plant.put("tag", custom);
+
+ ListTag contents = new ListTag();
+
+ contents.add(plant);
+ tag.put("Items", contents);
+ basket.put("tag", tag);
+
+ LegacyItemRecovery recovery = new LegacyItemRecovery();
+
+ recovery.fixTagCompound(basket);
+
+ CompoundTag converted = basket.getCompoundOrEmpty("tag").getListOrEmpty("Items").getCompoundOrEmpty(0);
+ ItemStack decoded = BopProbeItems.read(converted);
+
+ var expectedPlant = source.equals("biomesoplenty:sapling_0")
+ ? net.minecraft.world.item.Items.POPLAR_SAPLING : net.minecraft.world.item.Items.OAK_SAPLING;
+ require(decoded.is(expectedPlant), "Legacy sapling no longer has a live replacement");
+ require(decoded.getHoverName().getString().equals("Saved tree"), "Legacy plant name is readable");
+ require(decoded.getCount() == 23 && converted.getByteOr("Slot", (byte) 0) == 4, "Legacy slot and count");
+ require(BopProbeItems.custom(decoded).getStringOr("unrelated", "").equals("Keep this data"), "Unrelated plant NBT");
+
+ CompoundTag once = basket.copy();
+
+ recovery.fixTagCompound(basket);
+ require(basket.equals(once), "Legacy plant reload is idempotent");
+
+ CompoundTag invalid = once.copy();
+ CompoundTag child = invalid.getCompoundOrEmpty("tag").getListOrEmpty("Items").getCompoundOrEmpty(0);
+
+ child.putString("id", "biomesoplenty:sapling_0");
+ child.remove("count");
+ child.remove("components");
+ child.putByte("Count", (byte) 23);
+ child.putShort("Damage", (short) 16);
+
+ CompoundTag before = invalid.copy();
+ boolean stopped = false;
+
+ try {
+ recovery.fixTagCompound(invalid);
+ } catch (IllegalStateException expected) {
+ stopped = true;
+ }
+
+ require(stopped && before.equals(invalid), "Unknown plant variant stops atomically");
+ }
+
+ LOGGER.info("BOP_PLANT_RECOVERY_PASS names=true NBT=true slots=true reload=true malformed=blocked");
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RegistrySnapshot.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RegistrySnapshot.java
new file mode 100644
index 00000000..ba2a4e35
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RegistrySnapshot.java
@@ -0,0 +1,72 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import java.io.OutputStreamWriter;
+import java.io.FileOutputStream;
+import java.nio.charset.StandardCharsets;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraftforge.registries.ForgeRegistries;
+
+/** Keep the target's actual registry states beside the disposable upgrade receipts. */
+final class RegistrySnapshot {
+ private RegistrySnapshot() {
+ }
+
+ static void write() throws java.io.IOException {
+ JsonArray states = new JsonArray();
+
+ for (Block block : ForgeRegistries.BLOCKS.getValues()) {
+ if (!net.minecraftforge.registries.ForgeRegistries.BLOCKS.getKey(block).getNamespace().equals("biomesoplenty")) {
+ continue;
+ }
+ for (BlockState state : block.getStateDefinition().getPossibleStates()) {
+ JsonObject row = new JsonObject();
+
+ row.addProperty("id", net.minecraftforge.registries.ForgeRegistries.BLOCKS.getKey(block).toString());
+
+ JsonObject properties = new JsonObject();
+
+ state.getValues()
+ .forEach(property -> properties.addProperty(property.property().getName(),
+ property.value().toString()));
+ row.add("properties", properties);
+ states.add(row);
+ }
+ }
+ try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream("target-bop-states.json"),
+ StandardCharsets.UTF_8)) {
+ new GsonBuilder().setPrettyPrinting().create().toJson(states, writer);
+ }
+
+ JsonArray items = new JsonArray();
+
+ ForgeRegistries.ITEMS.getKeys().stream().filter(key -> key.getNamespace().equals("biomesoplenty"))
+ .sorted(java.util.Comparator.comparing(Object::toString)).forEach(key -> items.add(key.toString()));
+
+ try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream("target-bop-items.json"),
+ StandardCharsets.UTF_8)) {
+ new GsonBuilder().setPrettyPrinting().create().toJson(items, writer);
+ }
+
+ JsonArray biomes = new JsonArray();
+
+ var biomeRegistry = net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer().registryAccess()
+ .lookupOrThrow(net.minecraft.core.registries.Registries.BIOME);
+ biomeRegistry.forEach(biome -> {
+ JsonObject row = new JsonObject();
+
+ row.addProperty("id", biomeRegistry.getKey(biome).toString());
+ row.addProperty("temperature", biome.getBaseTemperature());
+ row.addProperty("rainfall", biome.getModifiedClimateSettings().downfall());
+ biomes.add(row);
+ });
+
+ try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream("target-biomes.json"),
+ StandardCharsets.UTF_8)) {
+ new GsonBuilder().setPrettyPrinting().create().toJson(biomes, writer);
+ }
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/SourceFluidChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/SourceFluidChecks.java
new file mode 100644
index 00000000..169be4b8
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/SourceFluidChecks.java
@@ -0,0 +1,101 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.io.ByteArrayInputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.HashSet;
+import java.util.HexFormat;
+import java.util.Set;
+import net.minecraft.core.BlockPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.nbt.TagParser;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
+
+/** Check saved water before ticking can move the range fixture's unsupported flows. */
+final class SourceFluidChecks {
+ private SourceFluidChecks() {
+ }
+
+ static Set verify(ServerLevel world) throws Exception {
+ JsonObject fixture = new JsonParser().parse(Files.readString(Path.of("forward-fixture.json")))
+ .getAsJsonObject();
+ Set flowing = new HashSet<>();
+ try (var input = SourceFluidChecks.class.getResourceAsStream("/fixtures/biomes/fluids/index.json")) {
+ var controls = new JsonParser().parse(new InputStreamReader(input, StandardCharsets.UTF_8))
+ .getAsJsonArray();
+ for (JsonElement entry : controls) {
+ JsonObject receipt = entry.getAsJsonObject();
+ if (!receipt.get("source_world_sha256").equals(fixture.get("archive_sha256"))) continue;
+ require(receipt.get("source_minecraft").equals(fixture.get("source_minecraft")),
+ "Water controls belong to a different Minecraft version");
+ Path temporary = Files.createTempDirectory(Path.of("."), "source-water-fixer-");
+ int cells = 0;
+ try (SimpleRegionStorage storage = new SimpleRegionStorage(new net.minecraft.world.level.chunk.storage.RegionStorageInfo("test", net.minecraft.world.level.Level.OVERWORLD, "chunk"), temporary, world.getServer().getFixerUpper(), false, net.minecraft.util.datafix.DataFixTypes.CHUNK)) {
+ for (JsonElement chunkEntry : receipt.getAsJsonArray("chunks")) {
+ JsonObject chunk = chunkEntry.getAsJsonObject();
+ byte[] bytes;
+ try (var source = SourceFluidChecks.class.getResourceAsStream(
+ "/fixtures/biomes/fluids/" + chunk.get("file").getAsString())) {
+ bytes = source.readAllBytes();
+ }
+ require(HexFormat.of().withUpperCase().formatHex(MessageDigest.getInstance("SHA-256")
+ .digest(bytes)).equals(chunk.get("sha256").getAsString()), "Saved water chunk changed");
+ CompoundTag source = NbtIo.readCompressed(new ByteArrayInputStream(bytes), net.minecraft.nbt.NbtAccounter.unlimitedHeap());
+ CompoundTag before = source.copy();
+ CompoundTag fixed = BopProbeItems.upgradeChunk(storage, source);
+ require(source.equals(before), "The independent source water tag changed");
+ for (JsonElement positionEntry : chunk.getAsJsonArray("positions")) {
+ JsonObject row = positionEntry.getAsJsonObject();
+ BlockPos position = new BlockPos(row.get("x").getAsInt(), row.get("y").getAsInt(),
+ row.get("z").getAsInt());
+ CompoundTag expected = TagParser.parseCompoundFully(row.get("state").getAsString());
+ require(BopProbeItems.readSavedBlockState(expected) == BopProbeItems.readSavedBlockState(state(fixed, position)),
+ "Saved water was lost during native data fixing at " + position);
+ if (Integer.parseInt(expected.getCompoundOrEmpty("Properties").getStringOr("level", "")) > 0) {
+ require(flowing.add(position), "Repeated source water position");
+ }
+ cells++;
+ }
+ }
+ }
+ require(cells == 64 && flowing.size() == 60, "Incomplete native water conversion check");
+ org.apache.logging.log4j.LogManager.getLogger().info(
+ "BOP_NATIVE_FLOW_DATAFIX_PASS cells={} flowing={} source_bound=true", cells, flowing.size());
+ return flowing;
+ }
+ }
+ return flowing;
+ }
+
+ private static net.minecraft.nbt.Tag state(CompoundTag chunk, BlockPos position) {
+ ListTag sections = chunk.getListOrEmpty("sections");
+ for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) {
+ CompoundTag section = sections.getCompoundOrEmpty(sectionIndex);
+ if (section.getByteOr("Y", (byte) 0) != position.getY() >> 4) continue;
+ CompoundTag states = section.getCompoundOrEmpty("block_states");
+ ListTag palette = states.getListOrEmpty("palette");
+ require(!palette.isEmpty(), "Missing upgraded water palette");
+ if (palette.size() == 1) return palette.get(0);
+ int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(palette.size() - 1));
+ int perLong = 64 / bits;
+ int index = (position.getY() & 15) * 256 + (position.getZ() & 15) * 16 + (position.getX() & 15);
+ long number = states.getLongArray("data").orElseThrow()[index / perLong] >>> (index % perLong * bits);
+ return palette.get((int) (number & ((1L << bits) - 1)));
+ }
+ throw new AssertionError("Missing upgraded water section at " + position);
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) throw new AssertionError(message);
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/SourceNeighbourChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/SourceNeighbourChecks.java
new file mode 100644
index 00000000..d24447dd
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/SourceNeighbourChecks.java
@@ -0,0 +1,110 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.nbt.TagParser;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.block.DoorBlock;
+import net.minecraft.world.level.block.state.BlockState;
+
+/** Follow vanilla's door and chest updates in the old range fixtures. */
+final class SourceNeighbourChecks {
+ private static final Map SOURCE_CONTAINERS = new LinkedHashMap<>();
+ private static final Map CONTAINER_POSITIONS = new LinkedHashMap<>();
+
+ private SourceNeighbourChecks() {
+ }
+
+ static Map> expectedDoors(ServerLevel world, JsonObject aliases) throws Exception {
+ SOURCE_CONTAINERS.clear();
+ CONTAINER_POSITIONS.clear();
+ Map> result = new LinkedHashMap<>();
+ Path path = Path.of("forward-neighbours.json");
+ if (!Files.isRegularFile(path)) return result;
+ JsonObject controls = new JsonParser().parse(Files.readString(path)).getAsJsonObject();
+ JsonObject fixture = new JsonParser().parse(Files.readString(Path.of("forward-fixture.json"))).getAsJsonObject();
+ require(controls.get("source_world_sha256").equals(fixture.get("archive_sha256")),
+ "The source door controls belong to a different fixture");
+ require(controls.get("source_minecraft").equals(fixture.get("source_minecraft")),
+ "The source door controls belong to a different Minecraft version");
+ for (JsonElement entry : controls.getAsJsonArray("containers")) {
+ JsonObject row = entry.getAsJsonObject();
+ BlockPos position = new BlockPos(row.get("x").getAsInt(), row.get("y").getAsInt(), row.get("z").getAsInt());
+ require(SOURCE_CONTAINERS.put(position, state(row.get("state").getAsString(), aliases)) == null,
+ "Repeated source container position");
+ }
+ for (JsonElement entry : controls.getAsJsonArray("doors")) {
+ JsonObject row = entry.getAsJsonObject();
+ BlockPos position = new BlockPos(row.get("x").getAsInt(), row.get("y").getAsInt(), row.get("z").getAsInt());
+ BlockState source = state(row.get("source").getAsString(), aliases);
+ BlockState neighbour = state(row.get("neighbour").getAsString(), aliases);
+ Direction side = Direction.byName(row.get("side").getAsString());
+ require(source.getBlock() instanceof DoorBlock && neighbour.getBlock() == source.getBlock(),
+ "A door control changed block identity");
+ require(side == Direction.UP || side == Direction.DOWN, "A door control has no paired half");
+ require(source.getValue(DoorBlock.HALF) != neighbour.getValue(DoorBlock.HALF),
+ "A door control does not contain opposite halves");
+ BlockPos lowerPosition = side == Direction.UP ? position : position.below();
+ BlockState lower = side == Direction.UP ? source : neighbour;
+ BlockState upper = side == Direction.UP ? neighbour : source;
+ // These source halves disagree. A pending update can leave the original state,
+ // and either half can be validated first as neighbouring chunks become ready.
+ BlockState lowerFirst = lower.updateShape(world, world, lowerPosition, Direction.UP, lowerPosition.above(), upper, world.getRandom());
+ BlockState upperAfterLower = upper.updateShape(world, world, lowerPosition.above(), Direction.DOWN, lowerPosition, lowerFirst, world.getRandom());
+ BlockState upperFirst = upper.updateShape(world, world, lowerPosition.above(), Direction.DOWN, lowerPosition, lower, world.getRandom());
+ BlockState lowerAfterUpper = lower.updateShape(world, world, lowerPosition, Direction.UP, lowerPosition.above(), upperFirst, world.getRandom());
+ var expected = java.util.List.of(source, side == Direction.UP ? lowerFirst : upperAfterLower,
+ side == Direction.UP ? lowerAfterUpper : upperFirst);
+ require(result.put(position, expected) == null, "Repeated source door position");
+ }
+ return result;
+ }
+
+ static BlockPos containerPosition(ServerLevel world, BlockPos position) {
+ if (CONTAINER_POSITIONS.containsKey(position)) return CONTAINER_POSITIONS.get(position);
+ BlockPos storage = position;
+ BlockState source = SOURCE_CONTAINERS.get(position);
+ if (source != null) {
+ BlockState current = world.getBlockState(position);
+ require(source.getBlock() == current.getBlock(), "A source container changed identity");
+ var type = net.minecraft.world.level.block.ChestBlock.TYPE;
+ var facing = net.minecraft.world.level.block.ChestBlock.FACING;
+ require(source.getValue(facing) == current.getValue(facing), "A source chest changed facing");
+ if (source.getValue(type) == net.minecraft.world.level.block.state.properties.ChestType.SINGLE
+ && current.getValue(type) != net.minecraft.world.level.block.state.properties.ChestType.SINGLE
+ && (source.getValue(facing) == Direction.NORTH || source.getValue(facing) == Direction.EAST)) {
+ // Vanilla's chest upgrader swaps contents when it joins north/east-facing single chests.
+ storage = position.relative(net.minecraft.world.level.block.ChestBlock.getConnectedDirection(current));
+ BlockState otherSource = SOURCE_CONTAINERS.get(storage);
+ BlockState other = world.getBlockState(storage);
+ require(otherSource != null && otherSource.getBlock() == source.getBlock()
+ && otherSource.getValue(type) == net.minecraft.world.level.block.state.properties.ChestType.SINGLE
+ && otherSource.getValue(facing) == source.getValue(facing), "The joined chest has no matching source half");
+ require(other.getBlock() == current.getBlock() && other.getValue(type) == current.getValue(type).getOpposite()
+ && storage.relative(net.minecraft.world.level.block.ChestBlock.getConnectedDirection(other)).equals(position),
+ "The joined chest has inconsistent halves");
+ }
+ }
+ require(!CONTAINER_POSITIONS.containsValue(storage), "Two source containers resolve to one inventory");
+ CONTAINER_POSITIONS.put(position, storage);
+ return storage;
+ }
+
+ private static BlockState state(String text, JsonObject aliases) throws Exception {
+ var saved = TagParser.parseCompoundFully(text);
+ String name = saved.getStringOr("Name", "");
+ if (aliases.has(name)) saved.putString("Name", aliases.get(name).getAsString());
+ return BopProbeItems.readSavedBlockState(saved);
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) throw new AssertionError(message);
+ }
+}
diff --git a/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/WallRecoveryChecks.java b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/WallRecoveryChecks.java
new file mode 100644
index 00000000..b5422334
--- /dev/null
+++ b/src/bootstrapTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/WallRecoveryChecks.java
@@ -0,0 +1,107 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.mojang.serialization.Dynamic;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.TagParser;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtOps;
+import net.minecraft.nbt.NbtUtils;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.SharedConstants;
+import net.minecraft.util.datafix.fixes.References;
+import zone.moddev.mc.skysbuildingpiecesbop.LegacyItemRecovery;
+
+/** The old native BOP wall states must retain every arm through vanilla's real state fixer. */
+final class WallRecoveryChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ static void run(MinecraftServer server) throws Exception {
+ JsonObject source;
+ try (InputStreamReader input = new InputStreamReader(WallRecoveryChecks.class.getResourceAsStream(
+ "/fixtures/bop-1.15.2-native-catalogue.json"), StandardCharsets.UTF_8)) {
+ source = new Gson().fromJson(input, JsonObject.class);
+ }
+ int checked = 0;
+ for (JsonElement entry : source.getAsJsonArray("blocks")) {
+ JsonObject block = entry.getAsJsonObject();
+ String name = block.get("id").getAsString();
+ if (!name.equals("biomesoplenty:white_sandstone_wall")
+ && !name.equals("biomesoplenty:mud_brick_wall")) continue;
+ for (JsonElement value : block.getAsJsonArray("states")) {
+ CompoundTag state = TagParser.parseCompoundFully(value.getAsString());
+ CompoundTag root = palette(state.copy());
+ new LegacyItemRecovery().fixTagCompound(root);
+ CompoundTag prepared = root.getCompoundOrEmpty("Level").getListOrEmpty("Sections").getCompoundOrEmpty(0)
+ .getListOrEmpty("Palette").getCompoundOrEmpty(0);
+ CompoundTag fixed = (CompoundTag) server.getFixerUpper().update(References.BLOCK_STATE,
+ new Dynamic<>(NbtOps.INSTANCE, prepared), 2230,
+ SharedConstants.getCurrentVersion().dataVersion().version()).getValue();
+ BlockState actual = BopProbeItems.readSavedBlockState(fixed);
+ CompoundTag result = NbtUtils.writeBlockState(actual).getCompoundOrEmpty("properties");
+ CompoundTag original = state.getCompoundOrEmpty("Properties");
+ for (String direction : new String[] {"north", "south", "east", "west"}) {
+ String expected = original.getStringOr(direction, "").equals("true") ? "low" : "none";
+ if (!result.getStringOr(direction, "").equals(expected)) {
+ throw new AssertionError("Native BOP wall lost its " + direction + " arm: " + state + " -> " + fixed);
+ }
+ }
+ for (String property : new String[] {"up", "waterlogged"}) {
+ if (!result.getStringOr(property, "").equals(original.getStringOr(property, ""))) {
+ throw new AssertionError("Native BOP wall changed " + property + ": " + state);
+ }
+ }
+ CompoundTag stack = new CompoundTag();
+ stack.putString("id", name);
+ stack.putByte("Count", (byte) 37);
+ stack.putByte("Slot", (byte) 11);
+ CompoundTag itemData = new CompoundTag();
+ itemData.put("BlockStateTag", original.copy());
+ itemData.putString("custom_data", "Keep this exactly");
+ stack.put("tag", itemData);
+ new LegacyItemRecovery().fixTagCompound(stack);
+ if (stack.getByteOr("Count", (byte) 0) != 37 || stack.getByteOr("Slot", (byte) 0) != 11
+ || !stack.getCompoundOrEmpty("tag").getStringOr("custom_data", "").equals("Keep this exactly")) {
+ throw new AssertionError("BOP wall item lost its count, slot or custom data");
+ }
+ for (String direction : new String[] {"north", "south", "east", "west"}) {
+ String expected = original.getStringOr(direction, "").equals("true") ? "low" : "none";
+ if (!stack.getCompoundOrEmpty("tag").getCompoundOrEmpty("BlockStateTag").getStringOr(direction, "").equals(expected)) {
+ throw new AssertionError("BOP wall item lost its " + direction + " connection");
+ }
+ }
+ CompoundTag once = stack.copy();
+ new LegacyItemRecovery().fixTagCompound(stack);
+ if (!once.equals(stack)) {
+ throw new AssertionError("BOP wall item changed on reload");
+ }
+ CompoundTag before = root.copy();
+ new LegacyItemRecovery().fixTagCompound(root);
+ if (!before.equals(root)) throw new AssertionError("BOP wall recovery repeated");
+ checked++;
+ }
+ }
+ if (checked != 128) throw new AssertionError("Native wall coverage changed: " + checked);
+ LOGGER.info("BOP_NATIVE_WALL_RECOVERY_PASS states=" + checked);
+ }
+
+ private static CompoundTag palette(CompoundTag state) {
+ ListTag palette = new ListTag();
+ palette.add(state);
+ CompoundTag section = new CompoundTag();
+ section.put("Palette", palette);
+ ListTag sections = new ListTag();
+ sections.add(section);
+ CompoundTag level = new CompoundTag();
+ level.put("Sections", sections);
+ CompoundTag root = new CompoundTag();
+ root.put("Level", level);
+ return root;
+ }
+}
diff --git a/src/bootstrapTest/native-log4j2.xml b/src/bootstrapTest/native-log4j2.xml
new file mode 100644
index 00000000..ce263e1e
--- /dev/null
+++ b/src/bootstrapTest/native-log4j2.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/bootstrapTest/resources/META-INF/mods.toml b/src/bootstrapTest/resources/META-INF/mods.toml
new file mode 100644
index 00000000..8f6e0576
--- /dev/null
+++ b/src/bootstrapTest/resources/META-INF/mods.toml
@@ -0,0 +1,7 @@
+modLoader="javafml"
+loaderVersion="[31,)"
+license="LGPL-2.1-only"
+[[mods]]
+modId="buildingpiecesbopchecks"
+version="1.0"
+displayName="Building Pieces BOP Native Checks"
diff --git a/src/bootstrapTest/resources/pack.mcmeta b/src/bootstrapTest/resources/pack.mcmeta
new file mode 100644
index 00000000..3ff5b389
--- /dev/null
+++ b/src/bootstrapTest/resources/pack.mcmeta
@@ -0,0 +1,7 @@
+{
+ "pack": {
+ "description": "Build-only BOP verification resources",
+ "max_format": 121,
+ "min_format": 121
+ }
+}
diff --git a/src/compatibilityTest/resources/META-INF/mods.toml b/src/compatibilityTest/resources/META-INF/mods.toml
new file mode 100644
index 00000000..0db899fa
--- /dev/null
+++ b/src/compatibilityTest/resources/META-INF/mods.toml
@@ -0,0 +1,8 @@
+modLoader="javafml"
+loaderVersion="[37,)"
+license="LGPL-2.1-only"
+[[mods]]
+modId="buildingbricks"
+version="1.0"
+displayName="Legacy Piece Policy Fixture"
+description="Build-only policy checks. Not a playable BuildingBricks port."
diff --git a/src/compatibilityTest/resources/pack.mcmeta b/src/compatibilityTest/resources/pack.mcmeta
new file mode 100644
index 00000000..532c9814
--- /dev/null
+++ b/src/compatibilityTest/resources/pack.mcmeta
@@ -0,0 +1,7 @@
+{
+ "pack": {
+ "description": "Legacy piece policy checks",
+ "max_format": 121,
+ "min_format": 121
+ }
+}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopServerProbe.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopServerProbe.java
new file mode 100644
index 00000000..c9ae9863
--- /dev/null
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpieces/probe/BopServerProbe.java
@@ -0,0 +1,111 @@
+package zone.moddev.mc.skysbuildingpieces.probe;
+
+import java.nio.file.Files;
+import java.util.Properties;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.gamerules.GameRules;
+import net.minecraft.world.level.storage.LevelResource;
+import net.minecraftforge.common.MinecraftForge;
+import net.minecraftforge.event.TickEvent;
+import org.apache.logging.log4j.LogManager;
+import zone.moddev.mc.skysbuildingpiecesbop.probe.BopChecks;
+
+/** Runs the shared gameplay checks and the larger BOP catalogue across normal server ticks. */
+public final class BopServerProbe {
+ private final MinecraftServer server;
+ private final ServerLevel world;
+ private final String phase;
+ private RecipeChecks.Audit recipes;
+ private RecombinationChecks.Audit combinations;
+ private int startupTicks;
+ private long nextBatch;
+ private net.minecraftforge.eventbus.api.listener.EventListener listener;
+
+ private BopServerProbe(MinecraftServer server, String phase) {
+ this.server = server;
+ this.world = server.overworld();
+ this.phase = phase;
+ }
+
+ public static void register(MinecraftServer server, String phase) {
+ BopServerProbe probe = new BopServerProbe(server, phase);
+ GameplayChecks.prepare(probe.world);
+ probe.listener = TickEvent.ServerTickEvent.Post.BUS.addListener(probe::tick);
+ }
+
+ public void tick(TickEvent.ServerTickEvent.Post event) {
+ if (System.nanoTime() < nextBatch) {
+ return;
+ }
+ try {
+ if (recipes == null) {
+ if (++startupTicks < 20 || !GameplayChecks.ready(world)) {
+ if (startupTicks >= 1200) {
+ throw new AssertionError("The BOP gameplay fixture never finished loading");
+ }
+ return;
+ }
+ server.getCommands().performPrefixedCommand(server.createCommandSourceStack().withSuppressedOutput(),
+ "gamerule minecraft:random_tick_speed 0");
+ server.getCommands().performPrefixedCommand(server.createCommandSourceStack().withSuppressedOutput(),
+ "gamerule minecraft:fire_spread_radius_around_player 0");
+ if (world.getGameRules().get(GameRules.RANDOM_TICK_SPEED) != 0
+ || world.getGameRules().get(GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER) != 0) {
+ throw new AssertionError("The disposable runtime rules were not applied");
+ }
+ recipes = new RecipeChecks.Audit(world);
+ }
+ if (combinations == null) {
+ if (recipes.advance(24)) {
+ recipes.finish();
+ NativeLegacyChecks.run(world);
+ GameplayChecks.run(world);
+ WaterloggingChecks.run(world);
+ PathChecks.run(world);
+ SharedGrassChecks.run(world);
+ MineralogyChecks.run();
+ combinations = new RecombinationChecks.Audit(world);
+ }
+ } else if (combinations.advance(4)) {
+ combinations.finish();
+ RecipeBookChecks.run(world);
+ PresentationChecks.run(world);
+ BopChecks.run(world);
+ finish();
+ stop();
+ return;
+ }
+ nextBatch = System.nanoTime() + 75_000_000L;
+ } catch (Throwable failure) {
+ LogManager.getLogger().error("BOP runtime check failed", failure);
+ stop();
+ }
+ }
+
+ private void finish() throws java.io.IOException {
+ var marker = server.getWorldPath(LevelResource.ROOT).resolve("skysbuildingpieces-integration.properties");
+ Properties result = new Properties();
+ if (Files.isRegularFile(marker)) {
+ try (var input = Files.newInputStream(marker)) {
+ result.load(input);
+ }
+ }
+ if (phase.equals("reload") && !"true".equals(result.getProperty("fresh_complete"))) {
+ throw new AssertionError("Fresh gameplay evidence is missing");
+ }
+ result.setProperty(phase + "_complete", "true");
+ result.setProperty("gameplay_checks", "placement, recipes, drops, paths, grass and walls");
+ result.setProperty("migration_checks", "source registry, all 16 step codes, raw inventories and native data fixing");
+ try (var output = Files.newOutputStream(marker)) {
+ result.store(output, "Disposable runtime checks");
+ }
+ LogManager.getLogger().info("BUILDING_PIECES_PROBE_PASS {}", phase);
+ }
+
+ private void stop() {
+ GameplayChecks.release(world);
+ TickEvent.ServerTickEvent.Post.BUS.removeListener(listener);
+ server.halt(false);
+ }
+}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketChecks.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketChecks.java
index 78a66b06..c06a2c94 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketChecks.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketChecks.java
@@ -1,232 +1,276 @@
package zone.moddev.mc.skysbuildingpiecesbop.probe;
-import net.minecraft.entity.item.EntityItem;
-import net.minecraft.entity.player.EntityPlayerMP;
-import net.minecraft.init.*;
-import net.minecraft.inventory.ClickType;
-import net.minecraft.item.ItemStack;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.inventory.AbstractContainerMenu;
+import net.minecraft.world.inventory.CraftingContainer;
+import net.minecraft.world.item.crafting.Recipe;
+import net.minecraft.world.item.crafting.RecipeType;
+
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.inventory.ContainerInput;
+import net.minecraft.world.item.ItemStack;
import net.minecraft.nbt.*;
-import net.minecraft.util.EnumHand;
-import net.minecraft.world.WorldServer;
-import net.minecraftforge.common.util.FakePlayerFactory;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.server.level.ServerLevel;
import net.minecraftforge.event.entity.player.EntityItemPickupEvent;
import zone.moddev.mc.skysbuildingpiecesbop.basket.*;
/** Real item, container, pickup and raw-storage checks; never shipped in the mod. */
public final class BasketChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
private static int checks;
- public static void run(WorldServer world) {
+ public static boolean hidden(net.minecraft.world.item.Item item, ServerLevel world) {
+ net.minecraft.world.item.CreativeModeTabs.tryRebuildTabContents(
+ world.enabledFeatures(), true, world.registryAccess());
+ return net.minecraft.world.item.CreativeModeTabs.allTabs().stream()
+ .flatMap(tab -> tab.getDisplayItems().stream()).noneMatch(stack -> stack.is(item));
+ }
+
+ public static void run(ServerLevel world) {
checks = 0;
- EntityPlayerMP player = FakePlayerFactory.getMinecraft(world);
+ ServerPlayer player = BopProbeItems.player(world);
- player.inventory.clear();
- player.inventory.currentItem = 0;
+ player.getInventory().clearContent();
+ player.getInventory().setSelectedSlot(0);
try {
- net.minecraft.inventory.InventoryCrafting crafting = new net.minecraft.inventory.InventoryCrafting(
- new net.minecraft.inventory.Container() {
+ net.minecraft.world.inventory.CraftingContainer crafting = new net.minecraft.world.inventory.TransientCraftingContainer(
+ new net.minecraft.world.inventory.AbstractContainerMenu(null, 0) {
@Override
- public boolean canInteractWith(net.minecraft.entity.player.EntityPlayer player) {
+ public ItemStack quickMoveStack(Player player, int slot) {
+ return ItemStack.EMPTY;
+ }
+
+ @Override
+ public boolean stillValid(net.minecraft.world.entity.player.Player player) {
return true;
}
}, 3, 3);
for (int slot : new int[] { 1, 3, 5, 6, 7, 8 }) {
- crafting.setInventorySlotContents(slot, new ItemStack(Items.STICK));
+ crafting.setItem(slot, new ItemStack(Items.STICK));
}
- net.minecraft.item.crafting.IRecipe recipe = net.minecraft.item.crafting.CraftingManager
- .findMatchingRecipe(crafting, world);
+ net.minecraft.world.item.crafting.CraftingRecipe recipe = world.getServer()
+ .getRecipeManager().getRecipeFor(net.minecraft.world.item.crafting.RecipeType.CRAFTING, crafting.asCraftInput(), world)
+ .map(net.minecraft.world.item.crafting.RecipeHolder::value).orElse(null);
- require(recipe != null && recipe.getCraftingResult(crafting).getItem() == Baskets.ITEM
- && recipe.getCraftingResult(crafting).getCount() == 1, "real six-stick crafting recipe");
- require(Baskets.ITEM.getItemStackLimit(new ItemStack(Baskets.ITEM)) == 1, "one basket per stack");
- require(Baskets.HOLDER.getCreativeTab() == null, "hidden recovery item");
- require(BasketInventory.accepts(new ItemStack(Blocks.SAPLING)), "saplings accepted");
- require(BasketInventory.accepts(new ItemStack(Blocks.LEAVES)), "leaves accepted");
+ require(recipe != null && recipe.assemble(crafting.asCraftInput()).getItem() == Baskets.ITEM
+ && recipe.assemble(crafting.asCraftInput()).getCount() == 1, "real six-stick crafting recipe");
+ require(new ItemStack(Baskets.ITEM).getMaxStackSize() == 1, "one basket per stack");
+ if (net.minecraftforge.fml.loading.FMLEnvironment.dist == net.minecraftforge.api.distmarker.Dist.CLIENT) {
+ require(hidden(Baskets.HOLDER, world), "hidden recovery item");
+ }
+ require(BasketInventory.accepts(new ItemStack(Blocks.OAK_SAPLING)), "saplings accepted");
+ require(BasketInventory.accepts(new ItemStack(Blocks.OAK_LEAVES)), "leaves accepted");
+ require(BasketInventory.accepts(new ItemStack(Blocks.VINE)), "vines accepted");
+ require(BasketInventory.accepts(new ItemStack(Blocks.SHORT_DRY_GRASS)), "dry grass accepted");
+ require(BasketInventory.accepts(new ItemStack(Blocks.COBWEB)), "cobwebs accepted");
require(!BasketInventory.accepts(new ItemStack(Items.DIAMOND)), "non-plants rejected");
- require(!BasketInventory.accepts(new ItemStack(Items.WHEAT_SEEDS)), "retain block-item eligibility");
+ require(!BasketInventory.accepts(new ItemStack(Blocks.WOOL.white())), "wool rejected");
+ require(BasketInventory.accepts(new ItemStack(Items.WHEAT_SEEDS)),
+ "native crop seed block-items retain plant eligibility");
require(!BasketInventory.accepts(new ItemStack(Baskets.ITEM)), "no nested baskets");
ItemStack basket = new ItemStack(Baskets.ITEM);
- basket.setStackDisplayName("Test plants");
- basket.getTagCompound().setInteger("unrelated", 19);
+ BopProbeItems.name(basket, "Test plants");
+ CompoundTag custom = new CompoundTag();
+ custom.putInt("unrelated", 19);
+ BopProbeItems.setCustom(basket, custom);
- BasketInventory inventory = new BasketInventory(basket);
+ BasketInventory inventory = new BasketInventory(basket, BopProbeItems.registries());
- require(inventory.insert(new ItemStack(Blocks.SAPLING, 60)).isEmpty(), "initial insert");
- require(inventory.insert(new ItemStack(Blocks.SAPLING, 12)).isEmpty(), "merge and spill to next slot");
- require(inventory.getStackInSlot(0).getCount() == 64 && inventory.getStackInSlot(1).getCount() == 8,
+ require(inventory.insert(new ItemStack(Blocks.OAK_SAPLING, 60)).isEmpty(), "initial insert");
+ require(inventory.insert(new ItemStack(Blocks.OAK_SAPLING, 12)).isEmpty(), "merge and spill to next slot");
+ require(inventory.getItem(0).getCount() == 64 && inventory.getItem(1).getCount() == 8,
"merge before empty slots");
- require(basket.getTagCompound().getInteger("unrelated") == 19
- && basket.getDisplayName().equals("Test plants"), "custom data retained");
- require(new BasketInventory(basket).getStackInSlot(1).getCount() == 8, "storage reload");
+ require(BopProbeItems.custom(basket).getIntOr("unrelated", 0) == 19
+ && basket.getHoverName().getString().equals("Test plants"), "custom data retained");
+ require(new BasketInventory(basket, BopProbeItems.registries()).getItem(1).getCount() == 8, "storage reload");
+
+ ItemStack customPlant = new ItemStack(Blocks.BIRCH_SAPLING, 3);
+ BopProbeItems.name(customPlant, "Orchard saplings");
+ customPlant.set(net.minecraft.core.component.DataComponents.MAX_STACK_SIZE, 16);
+ CompoundTag plantData = new CompoundTag();
+ plantData.putString("plot", "orchard");
+ BopProbeItems.setCustom(customPlant, plantData);
+ inventory.setItem(4, customPlant);
+ ItemStack reloadedPlant = new BasketInventory(basket, world.registryAccess()).getItem(4);
+ require(ItemStack.matches(customPlant, reloadedPlant) && reloadedPlant.getMaxStackSize() == 16,
+ "component-rich contents survive a save and restore");
for (int entryIndex = 0; entryIndex < 18; entryIndex++) {
- inventory.setInventorySlotContents(entryIndex,
- new ItemStack(Blocks.SAPLING, entryIndex == 17 ? 60 : 64));
+ inventory.setItem(entryIndex,
+ new ItemStack(Blocks.OAK_SAPLING, entryIndex == 17 ? 60 : 64));
}
- ItemStack rest = inventory.insert(new ItemStack(Blocks.SAPLING, 12));
+ ItemStack rest = inventory.insert(new ItemStack(Blocks.OAK_SAPLING, 12));
require(rest.getCount() == 8, "partial insertion retains remainder");
- player.inventory.setInventorySlotContents(0, basket);
+ player.getInventory().setItem(0, basket);
- for (EnumHand hand : EnumHand.values()) {
- int index = hand == EnumHand.MAIN_HAND ? 0 : 40;
+ for (InteractionHand hand : InteractionHand.values()) {
+ int index = hand == InteractionHand.MAIN_HAND ? 0 : 40;
- player.inventory.setInventorySlotContents(0, ItemStack.EMPTY);
- player.inventory.setInventorySlotContents(index, basket);
+ player.getInventory().setItem(0, ItemStack.EMPTY);
+ player.getInventory().setItem(index, basket);
- BasketContainer container = new BasketContainer(player, hand);
+ BasketContainer container = new BasketContainer(1, player, hand);
- player.openContainer = container;
- require(container.canInteractWith(player), "opens exact " + hand);
+ player.containerMenu = container;
+ require(container.stillValid(player), "opens exact " + hand);
require(!Baskets.ITEM.onDroppedByPlayer(basket, player), "throwing owner is blocked before removal");
int other = index == 40 ? 0 : 40;
- player.inventory.setInventorySlotContents(index, new ItemStack(Items.STICK));
- player.inventory.setInventorySlotContents(other, basket);
- require(container.canInteractWith(player) && player.inventory.getStackInSlot(index) == basket
- && player.inventory.getStackInSlot(other).getItem() == Items.STICK,
+ player.getInventory().setItem(index, new ItemStack(Items.STICK));
+ player.getInventory().setItem(other, basket);
+ require(container.stillValid(player) && player.getInventory().getItem(index) == basket
+ && player.getInventory().getItem(other).getItem() == Items.STICK,
"hand-swap packet is reversed without copying");
if (index == 0) {
- for (ClickType type : ClickType.values()) {
- container.slotClick(45, 0, type, player);
- require(player.inventory.getStackInSlot(0) == basket, "owner click locked " + type);
+ for (ContainerInput type : ContainerInput.values()) {
+ container.clicked(45, 0, type, player);
+ require(player.getInventory().getItem(0) == basket, "owner click locked " + type);
}
}
- player.inventory.setInventorySlotContents(9, new ItemStack(Blocks.SAPLING, 5));
- container.slotClick(18, index, ClickType.SWAP, player);
- require(player.inventory.getStackInSlot(index) == basket, "swap owner locked");
- player.inventory.setInventorySlotContents(index, basket.copy());
- require(!container.canInteractWith(player), "copied stack is not the owner");
- player.inventory.setInventorySlotContents(index, basket);
- container.onContainerClosed(player);
+ player.getInventory().setItem(9, new ItemStack(Blocks.OAK_SAPLING, 5));
+ container.clicked(18, index, ContainerInput.SWAP, player);
+ require(player.getInventory().getItem(index) == basket, "swap owner locked");
+ player.getInventory().setItem(index, basket.copy());
+ require(!container.stillValid(player), "copied stack is not the owner");
+ player.getInventory().setItem(index, basket);
+ container.removed(player);
}
ItemStack editable = new ItemStack(Baskets.ITEM);
- player.inventory.clear();
- player.inventory.setInventorySlotContents(0, editable);
+ player.getInventory().clearContent();
+ player.getInventory().setItem(0, editable);
- BasketContainer editor = new BasketContainer(player, EnumHand.MAIN_HAND);
+ BasketContainer editor = new BasketContainer(1, player, InteractionHand.MAIN_HAND);
- player.openContainer = editor;
- player.inventory.setInventorySlotContents(9, new ItemStack(Blocks.SAPLING, 5));
- editor.transferStackInSlot(player, 18);
- require(editor.basket.getStackInSlot(0).getCount() == 5 && player.inventory.getStackInSlot(9).isEmpty(),
+ player.containerMenu = editor;
+ player.getInventory().setItem(9, new ItemStack(Blocks.OAK_SAPLING, 5));
+ editor.quickMoveStack(player, 18);
+ require(editor.basket.getItem(0).getCount() == 5 && player.getInventory().getItem(9).isEmpty(),
"shift-click inserts");
- player.inventory.setInventorySlotContents(9, new ItemStack(Items.DIAMOND));
- editor.transferStackInSlot(player, 18);
- require(editor.basket.getStackInSlot(1).isEmpty()
- && player.inventory.getStackInSlot(9).getItem() == Items.DIAMOND,
+ player.getInventory().setItem(9, new ItemStack(Items.DIAMOND));
+ editor.quickMoveStack(player, 18);
+ require(editor.basket.getItem(1).isEmpty()
+ && player.getInventory().getItem(9).getItem() == Items.DIAMOND,
"shift-click rejects unrelated items");
- editor.transferStackInSlot(player, 0);
+ editor.quickMoveStack(player, 0);
require(editor.basket.isEmpty(), "shift-click withdraws");
- editor.basket.setInventorySlotContents(3, new ItemStack(Items.DIAMOND, 2));
- require(new BasketInventory(editable).getStackInSlot(3).getCount() == 2,
+ editor.basket.setItem(3, new ItemStack(Items.DIAMOND, 2));
+ require(new BasketInventory(editable, BopProbeItems.registries()).getItem(3).getCount() == 2,
"stored non-plant remains recoverable");
- editor.transferStackInSlot(player, 3);
+ editor.quickMoveStack(player, 3);
require(editor.basket.isEmpty(), "stored non-plant can be withdrawn");
- editor.basket.insert(new ItemStack(Blocks.SAPLING, 11));
- editor.onContainerClosed(player);
- require(new BasketInventory(editable).getStackInSlot(0).getCount() == 11,
+ editor.basket.insert(new ItemStack(Blocks.OAK_SAPLING, 11));
+ editor.removed(player);
+ require(new BasketInventory(editable, BopProbeItems.registries()).getItem(0).getCount() == 11,
"close persists contents immediately");
- player.inventory.clear();
+ player.getInventory().clearContent();
ItemStack collected = new ItemStack(Baskets.ITEM);
- player.inventory.setInventorySlotContents(0, collected);
- player.inventory.setInventorySlotContents(1, new ItemStack(Baskets.ITEM));
- player.openContainer = player.inventoryContainer;
+ player.getInventory().setItem(0, collected);
+ player.getInventory().setItem(1, new ItemStack(Baskets.ITEM));
+ player.containerMenu = player.inventoryMenu;
- EntityItem entity = new EntityItem(world, 0, 100, 0, new ItemStack(Blocks.SAPLING, 7));
+ ItemEntity entity = new ItemEntity(world, 0, 100, 0, new ItemStack(Blocks.OAK_SAPLING, 7));
EntityItemPickupEvent event = new EntityItemPickupEvent(player, entity);
- Baskets.pickup(event);
- require(event.isCanceled() && entity.isDead, "complete pickup consumed once");
- require(new BasketInventory(collected).getStackInSlot(0).getCount() == 7, "first basket collects");
- require(!player.inventory.getStackInSlot(1).hasTagCompound(), "later basket unchanged");
+ require(Baskets.pickup(event) && !entity.isAlive(), "complete pickup consumed once");
+ require(new BasketInventory(collected, BopProbeItems.registries()).getItem(0).getCount() == 7, "first basket collects");
+ require(!BopProbeItems.hasCustom(player.getInventory().getItem(1)), "later basket unchanged");
- BasketInventory full = new BasketInventory(collected);
+ BasketInventory full = new BasketInventory(collected, BopProbeItems.registries());
for (int entryIndex = 0; entryIndex < 18; entryIndex++) {
- full.setInventorySlotContents(entryIndex, new ItemStack(Blocks.SAPLING, entryIndex == 0 ? 61 : 64));
+ full.setItem(entryIndex, new ItemStack(Blocks.OAK_SAPLING, entryIndex == 0 ? 61 : 64));
}
- entity = new EntityItem(world, 0, 100, 0, new ItemStack(Blocks.SAPLING, 9));
+ entity = new ItemEntity(world, 0, 100, 0, new ItemStack(Blocks.OAK_SAPLING, 9));
event = new EntityItemPickupEvent(player, entity);
- Baskets.pickup(event);
- require(event.isCanceled() && !entity.isDead && entity.getItem().getCount() == 6,
+ require(Baskets.pickup(event) && entity.isAlive() && entity.getItem().getCount() == 6,
"partial pickup leaves exact remainder for the next collision");
// Exercise the caller too: vanilla retains the stack reference from before the pickup event, so replacing
- // only EntityItem's stack is unsafe.
- player.inventory.clear();
- player.inventory.setInventorySlotContents(0, collected);
- player.capabilities.isCreativeMode = false;
- full = new BasketInventory(collected);
+ // only ItemEntity's stack is unsafe.
+ player.getInventory().clearContent();
+ player.getInventory().setItem(0, collected);
+ player.getAbilities().instabuild = false;
+ full = new BasketInventory(collected, BopProbeItems.registries());
for (int entryIndex = 0; entryIndex < 18; entryIndex++) {
- full.setInventorySlotContents(entryIndex, new ItemStack(Blocks.SAPLING, entryIndex == 0 ? 61 : 64));
+ full.setItem(entryIndex, new ItemStack(Blocks.OAK_SAPLING, entryIndex == 0 ? 61 : 64));
}
- entity = new EntityItem(world, 0, 100, 0, new ItemStack(Blocks.SAPLING, 9));
- entity.onCollideWithPlayer(player);
+ entity = new ItemEntity(world, 0, 100, 0, new ItemStack(Blocks.OAK_SAPLING, 9));
+ entity.playerTouch(player);
- if (!entity.isDead) {
- entity.onCollideWithPlayer(player);
+ if (entity.isAlive()) {
+ entity.playerTouch(player);
}
int loose = 0;
- for (ItemStack stack : player.inventory.mainInventory) {
- if (stack.getItem() == net.minecraft.item.Item.getItemFromBlock(Blocks.SAPLING)) {
+ for (ItemStack stack : player.getInventory().getNonEquipmentItems()) {
+ if (stack.getItem() == net.minecraft.world.item.Item.byBlock(Blocks.OAK_SAPLING)) {
loose += stack.getCount();
}
}
- require(new BasketInventory(collected).getStackInSlot(0).getCount() == 64 && loose == 6 && entity.isDead,
- "real partial pickup conserves all nine items");
+ require(new BasketInventory(collected, BopProbeItems.registries()).getItem(0).getCount() == 64 && loose == 6
+ && !entity.isAlive(), "real partial pickup conserves all nine items");
- for (int entryIndex = 1; entryIndex < player.inventory.mainInventory.size(); entryIndex++) {
- player.inventory.setInventorySlotContents(entryIndex, new ItemStack(Blocks.STONE, 64));
+ for (int entryIndex = 1; entryIndex < player.getInventory().getNonEquipmentItems().size(); entryIndex++) {
+ player.getInventory().setItem(entryIndex, new ItemStack(Blocks.STONE, 64));
}
- full = new BasketInventory(collected);
- full.setInventorySlotContents(0, new ItemStack(Blocks.SAPLING, 61));
- entity = new EntityItem(world, 0, 100, 0, new ItemStack(Blocks.SAPLING, 9));
- entity.onCollideWithPlayer(player);
- entity.onCollideWithPlayer(player);
- require(!entity.isDead && entity.getItem().getCount() == 6
- && new BasketInventory(collected).getStackInSlot(0).getCount() == 64,
+ full = new BasketInventory(collected, BopProbeItems.registries());
+ full.setItem(0, new ItemStack(Blocks.OAK_SAPLING, 61));
+ entity = new ItemEntity(world, 0, 100, 0, new ItemStack(Blocks.OAK_SAPLING, 9));
+ entity.playerTouch(player);
+ entity.playerTouch(player);
+ require(entity.isAlive() && entity.getItem().getCount() == 6
+ && new BasketInventory(collected, BopProbeItems.registries()).getItem(0).getCount() == 64,
"real full inventory retains six ground items");
- player.inventory.clear();
+ player.getInventory().clearContent();
ItemStack ownedBasket = new ItemStack(Baskets.ITEM);
- player.inventory.setInventorySlotContents(0, ownedBasket);
- entity = new EntityItem(world, 0, 100, 0, new ItemStack(Blocks.SAPLING, 4));
- entity.setOwner("OtherPickupOwner");
- entity.onCollideWithPlayer(player);
- require(!entity.isDead && entity.getItem().getCount() == 4 && new BasketInventory(ownedBasket).isEmpty(),
+ player.getInventory().setItem(0, ownedBasket);
+ entity = new ItemEntity(world, 0, 100, 0, new ItemStack(Blocks.OAK_SAPLING, 4));
+ entity.setTarget(new java.util.UUID(1, 2));
+ entity.playerTouch(player);
+ require(entity.isAlive() && entity.getItem().getCount() == 4 && new BasketInventory(ownedBasket, BopProbeItems.registries()).isEmpty(),
"respect vanilla exclusive pickup ownership");
- entity.setOwner(player.getName());
- entity.onCollideWithPlayer(player);
- require(entity.isDead && new BasketInventory(ownedBasket).getStackInSlot(0).getCount() == 4,
+ entity.setTarget(player.getUUID());
+ entity.playerTouch(player);
+ require(!entity.isAlive() && new BasketInventory(ownedBasket, BopProbeItems.registries()).getItem(0).getCount() == 4,
"the drop owner can collect plants");
rawRecovery();
- System.out.println("BOP_FLOWER_BASKET_PASS checks=" + checks
+ LOGGER.info("BOP_FLOWER_BASKET_PASS checks=" + checks
+ " slots=18 collection=true NBT=preserved reload=idempotent");
} finally {
- player.inventory.clear();
- player.openContainer = player.inventoryContainer;
+ player.getInventory().clearContent();
+ player.containerMenu = player.inventoryMenu;
}
}
@@ -234,80 +278,80 @@ private static void rawRecovery() {
BasketData fixer = new BasketData();
for (String registryId : new String[] { BasketData.OLD, BasketData.HOLDER, BasketData.ID }) {
- NBTTagCompound stack = new ItemStack(Baskets.ITEM).writeToNBT(new NBTTagCompound());
+ CompoundTag stack = BopProbeItems.legacy(new ItemStack(Baskets.ITEM));
- stack.setString("id", registryId);
+ stack.putString("id", registryId);
- NBTTagCompound tag = new NBTTagCompound();
+ CompoundTag tag = new CompoundTag();
- tag.setBoolean("BasketOpen", true);
- tag.setString("CustomName", "Archived plants");
- tag.setInteger("other", 77);
+ tag.putBoolean("BasketOpen", true);
+ tag.putString("CustomName", "Archived plants");
+ tag.putInt("other", 77);
- NBTTagList list = new NBTTagList();
- NBTTagCompound child = new ItemStack(Blocks.SAPLING, 53, 2).writeToNBT(new NBTTagCompound());
+ ListTag list = new ListTag();
+ CompoundTag child = BopProbeItems.legacy(new ItemStack(Blocks.BIRCH_SAPLING, 53));
- child.setByte("Slot", (byte) 17);
- list.appendTag(child);
- tag.setTag("Items", list);
- stack.setTag("tag", tag);
+ child.putByte("Slot", (byte) 17);
+ list.add(child);
+ tag.put("Items", list);
+ stack.put("tag", tag);
fixer.fixTagCompound(stack);
- require(stack.getString("id").equals(BasketData.ID), "canonical item ID");
- require(!stack.getCompoundTag("tag").getBoolean("BasketOpen")
- && stack.getCompoundTag("tag").getInteger("other") == 77, "clear only stale open state");
- require(stack.getCompoundTag("tag").getTagList("Items", 10).getCompoundTagAt(0).equals(child),
+ require(stack.getStringOr("id", "").equals(BasketData.ID), "canonical item ID");
+ require(!stack.getCompoundOrEmpty("tag").getBooleanOr("BasketOpen", false)
+ && stack.getCompoundOrEmpty("tag").getIntOr("other", 0) == 77, "clear only stale open state");
+ require(stack.getCompoundOrEmpty("tag").getListOrEmpty("Items").getCompoundOrEmpty(0).equals(child),
"raw child NBT unchanged");
- NBTTagCompound once = stack.copy();
+ CompoundTag once = stack.copy();
fixer.fixTagCompound(stack);
require(stack.equals(once), "raw reload idempotence");
- require(new BasketInventory(new ItemStack(stack)).getStackInSlot(17).getCount() == 53,
+ require(new BasketInventory(BopProbeItems.read(stack), BopProbeItems.registries()).getItem(17).getCount() == 53,
"deserialization preserved contents");
for (String carrier : new String[] { "Inventory", "EnderItems", "Items", "Equipment", "CustomHandler" }) {
- NBTTagCompound root = new NBTTagCompound();
- NBTTagList listCarrier = new NBTTagList();
- NBTTagCompound historical = once.copy();
+ CompoundTag root = new CompoundTag();
+ ListTag listCarrier = new ListTag();
+ CompoundTag historical = once.copy();
- historical.setString("id", BasketData.OLD);
- listCarrier.appendTag(historical);
- root.setTag(carrier, listCarrier);
+ historical.putString("id", BasketData.OLD);
+ listCarrier.add(historical);
+ root.put(carrier, listCarrier);
fixer.fixTagCompound(root);
- require(root.getTagList(carrier, 10).getCompoundTagAt(0).getString("id").equals(BasketData.ID),
+ require(root.getListOrEmpty(carrier).getCompoundOrEmpty(0).getStringOr("id", "").equals(BasketData.ID),
"nested carrier " + carrier);
}
for (String error : new String[] { "unknown", "duplicate", "badslot", "badcount", "fractional", "wronglist",
"badtag", "badcaps" }) {
- NBTTagCompound broken = once.copy();
- NBTTagCompound entry = broken.getCompoundTag("tag").getTagList("Items", 10).getCompoundTagAt(0);
+ CompoundTag broken = once.copy();
+ CompoundTag entry = broken.getCompoundOrEmpty("tag").getListOrEmpty("Items").getCompoundOrEmpty(0);
if (error.equals("unknown")) {
- entry.setString("id", "missing_test:flower");
+ entry.putString("id", "missing_test:flower");
}
if (error.equals("duplicate")) {
- broken.getCompoundTag("tag").getTagList("Items", 10).appendTag(entry.copy());
+ broken.getCompoundOrEmpty("tag").getListOrEmpty("Items").add(entry.copy());
}
if (error.equals("badslot")) {
- entry.setByte("Slot", (byte) 18);
+ entry.putByte("Slot", (byte) 18);
}
if (error.equals("badcount")) {
- entry.setByte("Count", (byte) 65);
+ entry.putByte("Count", (byte) 65);
}
if (error.equals("fractional")) {
- entry.setFloat("Slot", .5f);
+ entry.putFloat("Slot", .5f);
}
if (error.equals("wronglist")) {
- broken.getCompoundTag("tag").setString("Items", "not an inventory");
+ broken.getCompoundOrEmpty("tag").putString("Items", "not an inventory");
}
if (error.equals("badtag")) {
- entry.setString("tag", "not item data");
+ entry.putString("tag", "not item data");
}
if (error.equals("badcaps")) {
- entry.setString("ForgeCaps", "not capability data");
+ entry.putString("ForgeCaps", "not capability data");
}
- NBTTagCompound before = broken.copy();
+ CompoundTag before = broken.copy();
boolean stopped = false;
try {
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketClientChecks.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketClientChecks.java
index d9bd899c..4fa684e2 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketClientChecks.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketClientChecks.java
@@ -1,68 +1,87 @@
package zone.moddev.mc.skysbuildingpiecesbop.probe;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+
import net.minecraft.client.Minecraft;
-import net.minecraft.client.renderer.block.model.IBakedModel;
-import net.minecraft.item.ItemStack;
+import net.minecraft.client.renderer.item.ItemStackRenderState;
+import net.minecraft.client.renderer.texture.TextureAtlasSprite;
+import net.minecraft.util.RandomSource;
+import net.minecraft.world.item.ItemDisplayContext;
+import net.minecraft.world.item.ItemStack;
import net.minecraft.nbt.*;
import net.minecraftforge.fml.common.Mod;
-import net.minecraftforge.fml.common.eventhandler.*;
-import net.minecraftforge.fml.common.gameevent.TickEvent;
-import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.event.TickEvent;
+
import zone.moddev.mc.skysbuildingpiecesbop.basket.*;
/** Verify both basket variants against the real client atlas before the client probe exits. */
public final class BasketClientChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
private static boolean checked;
- @SubscribeEvent(
- priority = EventPriority.HIGHEST)
- public static void tick(TickEvent.ClientTickEvent event) {
- if (checked || event.phase != TickEvent.Phase.END
+ static void register() {
+ TickEvent.ClientTickEvent.Post.BUS.addListener(net.minecraftforge.eventbus.api.listener.Priority.HIGHEST,
+ BasketClientChecks::tick);
+ }
+
+ public static void tick(TickEvent.ClientTickEvent.Post event) {
+ if (checked
|| !System.getProperty("skysbuildingpieces.integrationPhase", "").startsWith("client")) {
return;
}
+ Minecraft minecraft = Minecraft.getInstance();
+ // The title screen can bake models before the item's components are bound.
+ if (minecraft.gui.overlay() != null || minecraft.level == null || minecraft.player == null) {
+ return;
+ }
checked = true;
+ TextureAtlasSprite previous;
- Minecraft minecraft = Minecraft.getMinecraft();
- IBakedModel previous = null;
-
- for (boolean filled : new boolean[] { false, true }) {
- ItemStack stack = new ItemStack(Baskets.ITEM);
+ for (net.minecraft.world.item.Item basket : new net.minecraft.world.item.Item[] {Baskets.ITEM, Baskets.HOLDER}) {
+ previous = null;
+ for (boolean filled : new boolean[] { false, true }) {
+ ItemStack stack = new ItemStack(basket);
- if (filled) {
- NBTTagCompound tag = new NBTTagCompound();
- NBTTagList entries = new NBTTagList();
- NBTTagCompound child = new ItemStack(net.minecraft.init.Blocks.SAPLING)
- .writeToNBT(new NBTTagCompound());
+ if (filled) {
+ CompoundTag tag = new CompoundTag();
+ ListTag entries = new ListTag();
+ CompoundTag child = BopProbeItems.legacy(new ItemStack(net.minecraft.world.level.block.Blocks.OAK_SAPLING));
- child.setByte("Slot", (byte) 0);
- entries.appendTag(child);
- tag.setTag("Items", entries);
- stack.setTagCompound(tag);
- }
+ child.putByte("Slot", (byte) 0);
+ entries.add(child);
+ tag.put("Items", entries);
+ BopProbeItems.setCustom(stack, tag);
+ }
- IBakedModel model = minecraft.getRenderItem().getItemModelWithOverrides(stack, null, null);
+ TextureAtlasSprite model = checkModel(minecraft, stack);
+ if (filled && model == previous) {
+ throw new AssertionError("Filled basket override did not change the model: "
+ + net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(basket));
+ }
- if (model == minecraft.getRenderItem().getItemModelMesher().getModelManager().getMissingModel()
- || model.getParticleTexture().getIconName().contains("missingno")) {
- throw new AssertionError("Missing basket model");
- }
- if (filled && model == previous) {
- throw new AssertionError("Filled basket override did not change the model");
+ previous = model;
}
-
- previous = model;
}
- for (net.minecraft.item.Item holder : zone.moddev.mc.skysbuildingpiecesbop.LegacyItemRecovery.HOLDERS) {
- IBakedModel model = minecraft.getRenderItem().getItemModelWithOverrides(new ItemStack(holder), null, null);
-
- if (model == minecraft.getRenderItem().getItemModelMesher().getModelManager().getMissingModel()
- || model.getParticleTexture().getIconName().contains("missingno")) {
- throw new AssertionError("Missing retired-item holder model");
- }
+ for (net.minecraft.world.item.Item holder : zone.moddev.mc.skysbuildingpiecesbop.LegacyItemRecovery.HOLDERS) {
+ checkModel(minecraft, new ItemStack(holder));
}
- System.out.println("BOP_BASKET_CLIENT_PASS empty=true filled=true atlas=true retired_item_models=3");
+ LOGGER.info("BOP_BASKET_CLIENT_PASS empty=true filled=true atlas=true retired_item_models=3");
+ }
+
+ private static TextureAtlasSprite checkModel(Minecraft minecraft, ItemStack stack) {
+ var state = new ItemStackRenderState();
+ minecraft.getItemModelResolver().updateForTopItem(state, stack, ItemDisplayContext.GUI,
+ minecraft.level, minecraft.player, 0);
+ var material = state.isEmpty() ? null : state.pickParticleMaterial(RandomSource.create(0));
+ TextureAtlasSprite sprite = material == null ? null : material.sprite();
+ if (sprite == null || sprite.contents().name().toString().contains("missingno")) {
+ throw new AssertionError("Missing basket or recovery item model: " + stack);
+ }
+ return sprite;
}
}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketDisconnectClientChecks.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketDisconnectClientChecks.java
index 7e14f44e..c8a715cb 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketDisconnectClientChecks.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketDisconnectClientChecks.java
@@ -2,63 +2,75 @@
import java.io.*;
import net.minecraft.client.Minecraft;
-import net.minecraft.client.gui.GuiMainMenu;
-import net.minecraft.entity.player.EntityPlayerMP;
-import net.minecraft.item.ItemStack;
-import net.minecraft.util.EnumHand;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.InteractionHand;
import net.minecraftforge.common.MinecraftForge;
-import net.minecraftforge.fml.common.eventhandler.*;
-import net.minecraftforge.fml.common.gameevent.TickEvent;
+import net.minecraftforge.event.TickEvent;
import zone.moddev.mc.skysbuildingpieces.probe.ClientStatus;
import zone.moddev.mc.skysbuildingpiecesbop.basket.*;
import zone.moddev.mc.skysbuildingpiecesbop.client.BasketScreen;
/** Disconnect with the window still open, then inspect the normal player save on reconnect. */
public final class BasketDisconnectClientChecks {
- private static int stage, ticks;
- private static boolean controlled;
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private static int stage, ticks, windowReadyAt;
+ private static boolean ready;
private static volatile boolean checked;
+ private static Thread closingServerThread;
+ private static File savedPlayer;
+
+ static void register() {
+ TickEvent.ClientTickEvent.Post.BUS.addListener(net.minecraftforge.eventbus.api.listener.Priority.HIGHEST,
+ BasketDisconnectClientChecks::tick);
+ }
- @SubscribeEvent(
- priority = EventPriority.HIGHEST)
- public static void tick(TickEvent.ClientTickEvent event) {
+ public static void tick(TickEvent.ClientTickEvent.Post event) {
String phase = System.getProperty("skysbuildingpieces.integrationPhase", "");
- if (event.phase != TickEvent.Phase.END || !phase.startsWith("client-basket-disconnect")) {
+ if (!phase.startsWith("client-basket-disconnect")) {
return;
}
+ ready |= ClientStatus.complete;
ClientStatus.complete = false;
- Minecraft minecraft = Minecraft.getMinecraft();
+ Minecraft minecraft = Minecraft.getInstance();
- minecraft.gameSettings.pauseOnLostFocus = false;
+ minecraft.options.pauseOnLostFocus = false;
if (++ticks > 4000) {
throw new AssertionError("Open-basket disconnect check timed out at stage " + stage);
}
- if (!controlled && minecraft.getIntegratedServer() != null) {
- MinecraftForge.EVENT_BUS.unregister(zone.moddev.mc.skysbuildingpieces.probe.RuntimeProbe.proxy);
- controlled = true;
+ if (stage == 3) {
+ if (!closingServerThread.isAlive()) {
+ checkSavedBasket();
+ finish(minecraft, "BOP_BASKET_DISCONNECT_PASS window=open units=11 server_stopped=true saved_baskets=1");
+ }
+ return;
}
- if (minecraft.player == null || minecraft.getIntegratedServer() == null) {
+ // The server ignores interaction packets until the client has loaded its level.
+ if (!ready || minecraft.player == null || minecraft.getSingleplayerServer() == null
+ || minecraft.getConnection() == null || !minecraft.getConnection().hasClientLoaded()) {
return;
}
if (stage == 0) {
stage = phase.endsWith("reload") ? 10 : 1;
- minecraft.getIntegratedServer().addScheduledTask(() -> {
- EntityPlayerMP player = minecraft.getIntegratedServer().getPlayerList()
- .getPlayerByUUID(minecraft.player.getUniqueID());
+ minecraft.getSingleplayerServer().execute(() -> {
+ ServerPlayer player = minecraft.getSingleplayerServer().getPlayerList()
+ .getPlayer(minecraft.player.getUUID());
if (phase.endsWith("reload")) {
int baskets = 0;
- for (ItemStack stack : player.inventory.mainInventory) {
+ for (ItemStack stack : player.getInventory().getNonEquipmentItems()) {
if (stack.getItem() == Baskets.ITEM) {
baskets++;
- if (!stack.getDisplayName().equals("Disconnect plants")
- || new BasketInventory(stack).getStackInSlot(7).getCount() != 11) {
+ if (!stack.getHoverName().getString().equals("Disconnect plants")
+ || new BasketInventory(stack, BopProbeItems.registries()).getItem(7).getCount() != 11) {
throw new AssertionError("Disconnect changed the basket name, slot or contents");
}
}
@@ -69,42 +81,80 @@ public static void tick(TickEvent.ClientTickEvent event) {
checked = true;
} else {
- player.inventory.clear();
- player.inventory.currentItem = 0;
+ player.getInventory().clearContent();
+ player.getInventory().setSelectedSlot(0);
ItemStack stack = new ItemStack(Baskets.ITEM);
- stack.setStackDisplayName("Disconnect plants");
- new BasketInventory(stack).setInventorySlotContents(7,
- new ItemStack(net.minecraft.init.Blocks.SAPLING, 11, 2));
- player.inventory.setInventorySlotContents(0, stack);
- player.inventoryContainer.detectAndSendChanges();
+ BopProbeItems.name(stack, "Disconnect plants");
+ new BasketInventory(stack, BopProbeItems.registries()).setItem(7,
+ new ItemStack(net.minecraft.world.level.block.Blocks.BIRCH_SAPLING, 11));
+ player.getInventory().setItem(0, stack);
+ player.inventoryMenu.broadcastChanges();
}
});
} else if (stage == 10 && checked) {
finish(minecraft, "BOP_BASKET_DISCONNECT_RELOAD_PASS baskets=1 units=11 slot=7 custom_name=preserved");
- } else if (stage == 1 && minecraft.player.getHeldItemMainhand().getItem() == Baskets.ITEM) {
+ } else if (stage == 1 && minecraft.player.getMainHandItem().getItem() == Baskets.ITEM) {
stage = 2;
- minecraft.playerController.processRightClick(minecraft.player, minecraft.world, EnumHand.MAIN_HAND);
- } else if (stage == 2 && minecraft.currentScreen instanceof BasketScreen) {
+ minecraft.gameMode.useItem(minecraft.player, InteractionHand.MAIN_HAND);
+ } else if (stage == 2 && minecraft.gui.screen() instanceof BasketScreen) {
+ if (windowReadyAt == 0) {
+ windowReadyAt = ticks;
+ }
+ ItemStack firstStored = minecraft.player.containerMenu.getSlot(7).getItem();
+ if (firstStored.getCount() != 11 || ticks - windowReadyAt < 20) {
+ return;
+ }
stage = 3;
- minecraft.world.sendQuittingDisconnectingPacket();
- minecraft.loadWorld(null);
- minecraft.displayGuiScreen(new GuiMainMenu());
- finish(minecraft, "BOP_BASKET_DISCONNECT_PASS window=open units=11");
+ savedRegistries = minecraft.player.level().registryAccess();
+ closingServerThread = minecraft.getSingleplayerServer().getRunningThread();
+ savedPlayer = minecraft.getSingleplayerServer()
+ .getWorldPath(net.minecraft.world.level.storage.LevelResource.PLAYER_DATA_DIR)
+ .resolve(minecraft.player.getUUID() + ".dat").toFile();
+ minecraft.getConnection().getConnection().disconnect(
+ net.minecraft.network.chat.Component.translatable("disconnect.quitting"));
+ // Keep the client alive until the normal server thread has finished
+ // saving. Exiting the JVM here can interrupt the player-file rename.
+ }
+ }
+
+ private static net.minecraft.core.HolderLookup.Provider savedRegistries;
+
+ private static void checkSavedBasket() {
+ try (InputStream input = new FileInputStream(savedPlayer)) {
+ net.minecraft.nbt.ListTag inventory = net.minecraft.nbt.NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap())
+ .getListOrEmpty("Inventory");
+ int baskets = 0;
+ for (int slot = 0; slot < inventory.size(); slot++) {
+ ItemStack stack = ItemStack.CODEC.parse(savedRegistries.createSerializationContext(net.minecraft.nbt.NbtOps.INSTANCE),
+ inventory.getCompoundOrEmpty(slot)).getOrThrow();
+ if (stack.getItem() == Baskets.ITEM) {
+ baskets++;
+ if (!stack.getHoverName().getString().equals("Disconnect plants")
+ || new BasketInventory(stack, savedRegistries).getItem(7).getCount() != 11) {
+ throw new AssertionError("The stopped server did not save the complete basket");
+ }
+ }
+ }
+ if (baskets != 1) {
+ throw new AssertionError("The stopped server must save exactly one basket: " + baskets);
+ }
+ } catch (IOException failure) {
+ throw new IllegalStateException("Cannot inspect the normal saved player file", failure);
}
}
private static void finish(Minecraft minecraft, String marker) {
- try (PrintWriter writer = new PrintWriter(new File(minecraft.gameDir,
+ try (PrintWriter writer = new PrintWriter(new File(minecraft.gameDirectory,
stage == 3 ? "basket-disconnect-pass.txt" : "basket-disconnect-reload-pass.txt"), "UTF-8")) {
writer.println(marker);
} catch (IOException failure) {
throw new RuntimeException(failure);
}
- System.out.println(marker);
+ LOGGER.info(marker);
stage = 4;
- minecraft.shutdown();
+ minecraft.stop();
}
}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketGuiClientChecks.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketGuiClientChecks.java
index 62b2e518..bf8ba5d9 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketGuiClientChecks.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketGuiClientChecks.java
@@ -1,31 +1,47 @@
package zone.moddev.mc.skysbuildingpiecesbop.probe;
+import net.minecraft.world.InteractionHand;
+
import java.io.*;
import net.minecraft.client.Minecraft;
-import net.minecraft.entity.player.EntityPlayerMP;
-import net.minecraft.inventory.ClickType;
-import net.minecraft.item.ItemStack;
-import net.minecraft.nbt.NBTTagCompound;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.inventory.ContainerInput;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.nbt.CompoundTag;
import net.minecraft.util.*;
import net.minecraftforge.fml.common.Mod;
-import net.minecraftforge.fml.common.eventhandler.*;
-import net.minecraftforge.fml.common.gameevent.TickEvent;
-import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.event.TickEvent;
+
import zone.moddev.mc.skysbuildingpieces.probe.ClientStatus;
import zone.moddev.mc.skysbuildingpiecesbop.basket.*;
import zone.moddev.mc.skysbuildingpiecesbop.client.BasketScreen;
/** Normal GUI packets between a packaged client and its integrated server. */
public final class BasketGuiClientChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
+ private static final String TEST_BASKET = "network-" + java.util.UUID.randomUUID();
private static int stage, ticks, lastStage = -1, deathCheckAt;
- private static boolean ready, controlled;
+ private static boolean ready, controlled, respawnChecked;
private static volatile boolean serverChecked;
+ private static volatile boolean depositConfirmed;
+ private static volatile boolean deathTriggered;
+ private static int deathDrops = -1;
+ private static int throwCheckAt;
+ private static volatile boolean offhandMoved;
+
+ static void register() {
+ TickEvent.ClientTickEvent.Post.BUS.addListener(net.minecraftforge.eventbus.api.listener.Priority.HIGHEST,
+ BasketGuiClientChecks::tick);
+ }
- @SubscribeEvent(
- priority = EventPriority.HIGHEST)
- public static void tick(TickEvent.ClientTickEvent event) {
- if (event.phase != TickEvent.Phase.END
- || !System.getProperty("skysbuildingpieces.integrationPhase", "").equals("client-basket")) {
+ public static void tick(TickEvent.ClientTickEvent.Post event) {
+ if (!System.getProperty("skysbuildingpieces.integrationPhase", "").equals("client-basket")) {
+ return;
+ }
+ if (stage == 10) {
return;
}
@@ -36,70 +52,81 @@ public static void tick(TickEvent.ClientTickEvent event) {
throw new AssertionError("Basket GUI network test timed out at stage " + stage);
}
- Minecraft minecraft = Minecraft.getMinecraft();
+ Minecraft minecraft = Minecraft.getInstance();
- minecraft.gameSettings.pauseOnLostFocus = false;
+ minecraft.options.pauseOnLostFocus = false;
if (stage != lastStage) {
- System.out.println("BASKET_GUI_STAGE " + stage);
+ LOGGER.info("BASKET_GUI_STAGE " + stage);
lastStage = stage;
}
- if (!controlled && minecraft.getIntegratedServer() != null) {
- net.minecraftforge.common.MinecraftForge.EVENT_BUS
- .unregister(zone.moddev.mc.skysbuildingpieces.probe.RuntimeProbe.proxy);
- controlled = true;
- }
- if (!ready || minecraft.player == null || minecraft.getIntegratedServer() == null) {
+ // The server ignores interaction packets until the client has loaded its level.
+ if (!ready || minecraft.player == null || minecraft.getSingleplayerServer() == null
+ || minecraft.getConnection() == null || !minecraft.getConnection().hasClientLoaded()) {
return;
}
+ if (stage == 3 && !depositConfirmed && ticks % 5 == 0) {
+ minecraft.getSingleplayerServer().execute(() -> {
+ ServerPlayer player = minecraft.getSingleplayerServer().getPlayerList().getPlayer(minecraft.player.getUUID());
+ if (player.containerMenu instanceof BasketContainer menu && menu.basket.getItem(0).getCount() == 5) {
+ depositConfirmed = true;
+ }
+ });
+ }
if (stage == 3 && ticks % 40 == 0) {
- System.out
- .println("BASKET_GUI_CLIENT_STATE container=" + minecraft.player.openContainer.getClass().getName()
- + " first=" + minecraft.player.openContainer.getSlot(0).getStack() + " can_interact="
- + minecraft.player.openContainer.canInteractWith(minecraft.player));
- minecraft.getIntegratedServer().addScheduledTask(() -> {
- EntityPlayerMP player = minecraft.getIntegratedServer().getPlayerList().getPlayerByUUID(minecraft.player.getUniqueID());
-
- System.out.println("BASKET_GUI_SERVER_STATE first=" + player.openContainer.getSlot(0).getStack() + " input="
- + player.inventory.getStackInSlot(9) + " can_interact=" + player.openContainer.canInteractWith(player));
+ LOGGER.info("BASKET_GUI_CLIENT_STATE container=" + minecraft.player.containerMenu.getClass().getName()
+ + " first=" + minecraft.player.containerMenu.getSlot(0).getItem() + " can_interact="
+ + minecraft.player.containerMenu.stillValid(minecraft.player));
+ minecraft.getSingleplayerServer().execute(() -> {
+ ServerPlayer player = minecraft.getSingleplayerServer().getPlayerList()
+ .getPlayer(minecraft.player.getUUID());
+
+ LOGGER.info("BASKET_GUI_SERVER_STATE first=" + player.containerMenu.getSlot(0).getItem()
+ + " input=" + player.getInventory().getItem(9) + " can_interact="
+ + player.containerMenu.stillValid(player));
});
}
if (stage == 0) {
stage = 1;
- minecraft.getIntegratedServer().addScheduledTask(() -> {
- EntityPlayerMP player = minecraft.getIntegratedServer().getPlayerList().getPlayerByUUID(minecraft.player.getUniqueID());
+ minecraft.getSingleplayerServer().execute(() -> {
+ ServerPlayer player = minecraft.getSingleplayerServer().getPlayerList()
+ .getPlayer(minecraft.player.getUUID());
- player.inventory.clear();
- player.inventory.currentItem = 0;
+ player.getInventory().clearContent();
+ player.getInventory().setSelectedSlot(0);
ItemStack basket = new ItemStack(Baskets.ITEM);
- NBTTagCompound tag = new NBTTagCompound();
+ CompoundTag tag = new CompoundTag();
- tag.setString("TestBasket", "network");
- basket.setTagCompound(tag);
- player.inventory.setInventorySlotContents(0, basket);
- player.inventory.setInventorySlotContents(9, new ItemStack(net.minecraft.init.Blocks.SAPLING, 5));
- player.inventoryContainer.detectAndSendChanges();
+ tag.putString("TestBasket", TEST_BASKET);
+ BopProbeItems.setCustom(basket, tag);
+ player.getInventory().setItem(0, basket);
+ player.getInventory().setItem(9, new ItemStack(net.minecraft.world.level.block.Blocks.OAK_SAPLING, 5));
+ player.inventoryMenu.broadcastChanges();
});
- } else if (stage == 1 && minecraft.player.getHeldItemMainhand().getItem() == Baskets.ITEM) {
+ } else if (stage == 1 && minecraft.player.getMainHandItem().getItem() == Baskets.ITEM) {
stage = 2;
- minecraft.playerController.processRightClick(minecraft.player, minecraft.world, EnumHand.MAIN_HAND);
- } else if (stage == 2 && minecraft.currentScreen instanceof BasketScreen) {
+ minecraft.gameMode.useItem(minecraft.player, InteractionHand.MAIN_HAND);
+ } else if (stage == 2 && minecraft.gui.screen() instanceof BasketScreen) {
stage = 3;
- minecraft.playerController.windowClick(minecraft.player.openContainer.windowId, 18, 0, ClickType.QUICK_MOVE,
+ minecraft.gameMode.handleContainerInput(minecraft.player.containerMenu.containerId, 18, 0, ContainerInput.QUICK_MOVE,
minecraft.player);
- } else if (stage == 3 && minecraft.player.openContainer instanceof BasketContainer
- && ((BasketContainer) minecraft.player.openContainer).basket.getStackInSlot(0).getCount() == 5) {
- net.minecraft.util.ScreenShotHelper.saveScreenshot(minecraft.gameDir, "flower-basket-gui.png",
- minecraft.displayWidth, minecraft.displayHeight, minecraft.getFramebuffer());
- minecraft.playerController.windowClick(minecraft.player.openContainer.windowId, 45, 1, ClickType.THROW,
+ } else if (stage == 3 && depositConfirmed && minecraft.player.containerMenu instanceof BasketContainer
+ && ((BasketContainer) minecraft.player.containerMenu).basket.getItem(0).getCount() == 5) {
+ net.minecraft.client.Screenshot.grab(minecraft.gameDirectory, "flower-basket-gui.png",
+ minecraft.gameRenderer.mainRenderTarget(), 1, message -> {
+ });
+ minecraft.gameMode.handleContainerInput(minecraft.player.containerMenu.containerId, 45, 1, ContainerInput.THROW,
minecraft.player);
stage = 4;
- minecraft.getIntegratedServer().addScheduledTask(() -> {
- EntityPlayerMP player = minecraft.getIntegratedServer().getPlayerList().getPlayerByUUID(minecraft.player.getUniqueID());
+ throwCheckAt = ticks + 10;
+ } else if (stage == 4 && ticks == throwCheckAt) {
+ minecraft.getSingleplayerServer().execute(() -> {
+ ServerPlayer player = minecraft.getSingleplayerServer().getPlayerList()
+ .getPlayer(minecraft.player.getUUID());
- if (player.inventory.getStackInSlot(0).getItem() != Baskets.ITEM
- || new BasketInventory(player.inventory.getStackInSlot(0)).getStackInSlot(0).getCount() != 5) {
+ if (player.getInventory().getItem(0).getItem() != Baskets.ITEM
+ || new BasketInventory(player.getInventory().getItem(0), BopProbeItems.registries()).getItem(0).getCount() != 5) {
throw new AssertionError("GUI throw lost the owner or its contents");
}
@@ -107,86 +134,127 @@ public static void tick(TickEvent.ClientTickEvent event) {
});
} else if (stage == 4 && serverChecked) {
serverChecked = false;
- minecraft.player.closeScreen();
+ minecraft.player.closeContainer();
stage = 5;
- minecraft.getIntegratedServer().addScheduledTask(() -> {
- EntityPlayerMP player = minecraft.getIntegratedServer().getPlayerList().getPlayerByUUID(minecraft.player.getUniqueID());
- ItemStack basket = player.inventory.getStackInSlot(0);
-
- player.inventory.setInventorySlotContents(0, ItemStack.EMPTY);
- player.setHeldItem(EnumHand.OFF_HAND, basket);
- player.inventoryContainer.detectAndSendChanges();
- });
- } else if (stage == 5 && minecraft.player.getHeldItemOffhand().getItem() == Baskets.ITEM) {
- stage = 6;
- minecraft.playerController.processRightClick(minecraft.player, minecraft.world, EnumHand.OFF_HAND);
- } else if (stage == 6 && minecraft.currentScreen instanceof BasketScreen) {
+ } else if (stage == 5) {
+ if (!offhandMoved && ticks % 5 == 0) {
+ minecraft.getSingleplayerServer().execute(() -> {
+ ServerPlayer player = minecraft.getSingleplayerServer().getPlayerList()
+ .getPlayer(minecraft.player.getUUID());
+ if (offhandMoved || player.containerMenu != player.inventoryMenu) {
+ return;
+ }
+ // Wait for the actual close packet. Moving the owner earlier
+ // correctly makes the basket rescue itself into its locked slot.
+ ItemStack basket = player.getInventory().getItem(0);
+ if (basket.getItem() != Baskets.ITEM || !BopProbeItems.hasCustom(basket)
+ || !BopProbeItems.custom(basket).getStringOr("TestBasket", "").equals(TEST_BASKET)
+ || new BasketInventory(basket, BopProbeItems.registries()).getItem(0).getCount() != 5) {
+ throw new AssertionError("The closed basket keeps its owner and five saplings");
+ }
+ player.getInventory().setItem(0, ItemStack.EMPTY);
+ player.setItemInHand(InteractionHand.OFF_HAND, basket);
+ player.inventoryMenu.broadcastChanges();
+ offhandMoved = true;
+ });
+ }
+ if (offhandMoved && minecraft.player.getOffhandItem().getItem() == Baskets.ITEM) {
+ stage = 6;
+ minecraft.gameMode.useItem(minecraft.player, InteractionHand.OFF_HAND);
+ }
+ } else if (stage == 6 && minecraft.gui.screen() instanceof BasketScreen) {
stage = 7;
- minecraft.playerController.windowClick(minecraft.player.openContainer.windowId, 0, 0, ClickType.QUICK_MOVE,
+ minecraft.gameMode.handleContainerInput(minecraft.player.containerMenu.containerId, 0, 0, ContainerInput.QUICK_MOVE,
minecraft.player);
- } else if (stage == 7 && minecraft.player.openContainer instanceof BasketContainer
- && ((BasketContainer) minecraft.player.openContainer).basket.isEmpty()) {
- stage = 8;
- minecraft.getIntegratedServer().addScheduledTask(() -> {
- EntityPlayerMP player = minecraft.getIntegratedServer().getPlayerList().getPlayerByUUID(minecraft.player.getUniqueID());
+ } else if (stage == 7 && !deathTriggered && ticks % 5 == 0
+ && minecraft.player.containerMenu instanceof BasketContainer menuOnClient && menuOnClient.basket.isEmpty()) {
+ minecraft.getSingleplayerServer().execute(() -> {
+ ServerPlayer player = minecraft.getSingleplayerServer().getPlayerList()
+ .getPlayer(minecraft.player.getUUID());
int plants = 0;
- for (ItemStack stack : player.inventory.mainInventory) {
- if (stack.getItem() == net.minecraft.item.Item
- .getItemFromBlock(net.minecraft.init.Blocks.SAPLING)) {
+ for (ItemStack stack : player.getInventory().getNonEquipmentItems()) {
+ if (stack.getItem() == net.minecraft.world.item.Item
+ .byBlock(net.minecraft.world.level.block.Blocks.OAK_SAPLING)) {
plants += stack.getCount();
}
}
- if (plants != 5 || player.getHeldItemOffhand().getItem() != Baskets.ITEM) {
+ if (!(player.containerMenu instanceof BasketContainer menu) || !menu.basket.isEmpty()) {
+ // The client predicts the slot change before the server handles the click.
+ return;
+ }
+ if (plants != 5 || player.getOffhandItem().getItem() != Baskets.ITEM) {
throw new AssertionError("Offhand withdrawal lost or duplicated items");
}
- ((BasketContainer) player.openContainer).basket.insert(new ItemStack(net.minecraft.init.Blocks.SAPLING, 3));
- player.capabilities.isCreativeMode = false;
+ if (deathTriggered) {
+ return;
+ }
+ menu.basket
+ .insert(new ItemStack(net.minecraft.world.level.block.Blocks.OAK_SAPLING, 3));
+ player.getAbilities().instabuild = false;
player.setHealth(0);
- player.onDeath(net.minecraft.util.DamageSource.OUT_OF_WORLD);
+ player.die(player.damageSources().fellOutOfWorld());
+
+ // The inventory now spawns its drops directly. Check the actual item entity.
+ var drops = player.level().getEntitiesOfClass(ItemEntity.class, player.getBoundingBox().inflate(2),
+ drop -> drop.getItem().is(Baskets.ITEM) && BopProbeItems.hasCustom(drop.getItem())
+ && BopProbeItems.custom(drop.getItem()).getStringOr("TestBasket", "").equals(TEST_BASKET));
+ deathDrops = drops.size();
+ for (ItemEntity drop : drops) {
+ if (new BasketInventory(drop.getItem(), player.registryAccess()).getItem(0).getCount() != 3) {
+ throw new AssertionError("Death lost basket contents");
+ }
+ }
+ deathTriggered = true;
});
+ } else if (stage == 7 && deathTriggered) {
+ stage = 8;
deathCheckAt = ticks + 20;
} else if (stage == 8 && ticks >= deathCheckAt && !serverChecked) {
deathCheckAt = Integer.MAX_VALUE;
- minecraft.getIntegratedServer().addScheduledTask(() -> {
- EntityPlayerMP player = minecraft.getIntegratedServer().getPlayerList().getPlayerByUUID(minecraft.player.getUniqueID());
- int dropped = 0;
-
- for (net.minecraft.entity.Entity entity : player.world.loadedEntityList) {
- if (entity instanceof net.minecraft.entity.item.EntityItem) {
- ItemStack stack = ((net.minecraft.entity.item.EntityItem) entity).getItem();
-
- if (stack.getItem() == Baskets.ITEM && stack.hasTagCompound()
- && stack.getTagCompound().getString("TestBasket").equals("network")) {
- dropped++;
-
- if (new BasketInventory(stack).getStackInSlot(0).getCount() != 3) {
- throw new AssertionError("Death lost basket contents");
- }
- }
- }
- }
- if (dropped != 1 || player.openContainer instanceof BasketContainer) {
- throw new AssertionError("Death did not close and drop exactly one basket: drops=" + dropped
- + " container=" + player.openContainer.getClass().getName());
+ minecraft.getSingleplayerServer().execute(() -> {
+ ServerPlayer player = minecraft.getSingleplayerServer().getPlayerList()
+ .getPlayer(minecraft.player.getUUID());
+ // A drop may leave the client's loaded chunks before the next check.
+ if (deathDrops != 1 || player.containerMenu instanceof BasketContainer) {
+ throw new AssertionError("Death did not close and drop exactly one basket: drops=" + deathDrops
+ + " container=" + player.containerMenu.getClass().getName());
}
-
+ LOGGER.info("BOP_BASKET_DEATH_DROP_PASS identity=" + TEST_BASKET);
serverChecked = true;
});
} else if (stage == 8 && serverChecked) {
- try (PrintWriter writer = new PrintWriter(new File(minecraft.gameDir, "basket-client-network-pass.txt"),
+ // Finish the death check with a normal respawn so reload starts
+ // with a live player who can open containers again.
+ serverChecked = false;
+ stage = 9;
+ minecraft.player.respawn();
+ minecraft.gui.setScreen(null);
+ } else if (stage == 9 && minecraft.player.isAlive() && !respawnChecked) {
+ respawnChecked = true;
+ minecraft.getSingleplayerServer().execute(() -> {
+ ServerPlayer player = minecraft.getSingleplayerServer().getPlayerList()
+ .getPlayer(minecraft.player.getUUID());
+
+ if (player == null || !player.isAlive() || player.getHealth() <= 0) {
+ throw new AssertionError("Basket death check did not respawn the player");
+ }
+ serverChecked = true;
+ });
+ } else if (stage == 9 && serverChecked) {
+ try (PrintWriter writer = new PrintWriter(new File(minecraft.gameDirectory, "basket-client-network-pass.txt"),
"UTF-8")) {
writer.println(
- "main_hand=true offhand=true shift_click=true owner_throw=blocked death_contents=preserved");
+ "main_hand=true offhand=true shift_click=true owner_throw=blocked death_contents=preserved respawn=true");
} catch (IOException failure) {
throw new RuntimeException(failure);
}
- System.out.println(
- "BOP_BASKET_GUI_NETWORK_PASS hands=2 shift_click=true owner_throw=blocked death_contents=3 dropped_baskets=1");
- stage = 9;
- minecraft.shutdown();
+ LOGGER.info(
+ "BOP_BASKET_GUI_NETWORK_PASS hands=2 shift_click=true owner_throw=blocked death_contents=3 dropped_baskets=1 respawn=true");
+ stage = 10;
+ ClientStatus.complete = true;
}
}
}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketPlayerUpgrade.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketPlayerUpgrade.java
index 48925607..930916cb 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketPlayerUpgrade.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketPlayerUpgrade.java
@@ -1,73 +1,110 @@
package zone.moddev.mc.skysbuildingpiecesbop.probe;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.nbt.Tag;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.storage.LevelResource;
+
import java.io.*;
import java.util.UUID;
import com.mojang.authlib.GameProfile;
import net.minecraft.nbt.*;
-import net.minecraft.entity.player.EntityPlayerMP;
-import net.minecraft.world.WorldServer;
-import net.minecraftforge.common.util.FakePlayerFactory;
-import net.minecraftforge.fml.common.*;
-import net.minecraftforge.fml.common.eventhandler.*;
-import net.minecraftforge.fml.common.gameevent.TickEvent;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraftforge.server.ServerLifecycleHooks;
+import net.minecraftforge.event.TickEvent;
import zone.moddev.mc.skysbuildingpiecesbop.basket.BasketData;
/** Exercise the real player-save loader only in explicitly selected disposable Sylvester tests. */
public final class BasketPlayerUpgrade {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
private static boolean ran;
- @SubscribeEvent(
- priority = EventPriority.HIGHEST)
- public static void tick(TickEvent.ServerTickEvent event) throws Exception {
- if (ran || event.phase != TickEvent.Phase.START
- || !System.getProperty("skysbuildingpieces.integrationPhase", "").startsWith("sylvester")) {
+ public static void tick(TickEvent.ServerTickEvent.Pre event) {
+ try {
+ audit();
+ } catch (Exception failure) {
+ throw new IllegalStateException("Cannot audit the disposable player saves", failure);
+ }
+ }
+
+ private static void audit() throws Exception {
+ if (ran || !System.getProperty("skysbuildingpieces.integrationPhase", "").startsWith("sylvester")) {
return;
}
ran = true;
- WorldServer world = FMLCommonHandler.instance().getMinecraftServerInstance().getWorld(0);
- File players = new File(world.getSaveHandler().getWorldDirectory(), "playerdata");
+ ServerLevel world = ServerLifecycleHooks.getCurrentServer()
+ .getLevel(net.minecraft.world.level.Level.OVERWORLD);
+ var server = world.getServer();
+ File legacyPlayers = server.getWorldPath(LevelResource.ROOT).resolve("playerdata").toFile();
+ File currentPlayers = server.getWorldPath(LevelResource.PLAYER_DATA_DIR).toFile();
+ // Read a profile once when Minecraft has already copied it to the new directory.
+ var profiles = new java.util.TreeMap();
+ for (File directory : new File[] {legacyPlayers, currentPlayers}) {
+ File[] files = directory.listFiles((parent, name) -> name.endsWith(".dat"));
+ if (files != null) {
+ for (File file : files) {
+ profiles.put(file.getName(), file);
+ }
+ }
+ }
int loaded = 0;
- File[] files = players.listFiles((directory, name) -> name.endsWith(".dat"));
+ for (File file : profiles.values()) {
+ CompoundTag raw;
+ try (InputStream input = new FileInputStream(file)) {
+ raw = NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap());
+ }
+ if (!hasBasket(raw)) {
+ continue;
+ }
+ UUID uuid = UUID.fromString(file.getName().substring(0, file.getName().length() - 4));
+ ServerPlayer player = BopProbeItems.player(world, new GameProfile(uuid, "MigrationTest"));
+ BopProbeItems.loadPlayer(player);
+ savePlayer(player);
+ loaded++;
+ }
- if (files != null) {
- for (File file : files) {
- NBTTagCompound raw;
+ LOGGER.info("BOP_BASKET_PLAYER_LOADER_PASS profiles=" + loaded);
+ }
- try (InputStream input = new FileInputStream(file)) {
- raw = CompressedStreamTools.readCompressed(input);
- }
- if (!hasBasket(raw)) {
- continue;
+ private static void savePlayer(ServerPlayer player) throws Exception {
+ java.lang.reflect.Method save = null;
+ for (java.lang.reflect.Method method : net.minecraft.server.players.PlayerList.class.getDeclaredMethods()) {
+ if (java.lang.reflect.Modifier.isProtected(method.getModifiers()) && method.getReturnType() == void.class
+ && java.util.Arrays.equals(method.getParameterTypes(), new Class>[] { ServerPlayer.class })) {
+ if (save != null) {
+ throw new IllegalStateException("The native player-save method is ambiguous");
}
-
- UUID uuid = UUID.fromString(file.getName().substring(0, file.getName().length() - 4));
- EntityPlayerMP player = FakePlayerFactory.get(world, new GameProfile(uuid, "MigrationTest"));
-
- world.getSaveHandler().getPlayerNBTManager().readPlayerData(player);
- world.getSaveHandler().getPlayerNBTManager().writePlayerData(player);
- loaded++;
+ save = method;
}
}
-
- System.out.println("BOP_BASKET_PLAYER_LOADER_PASS profiles=" + loaded);
+ if (save == null) {
+ throw new IllegalStateException("The native player-save method is missing");
+ }
+ save.setAccessible(true);
+ save.invoke(player.level().getServer().getPlayerList(), player);
}
- private static boolean hasBasket(NBTBase value) {
- if (value instanceof NBTTagCompound) {
- NBTTagCompound tag = (NBTTagCompound) value;
+ private static boolean hasBasket(Tag value) {
+ if (value instanceof CompoundTag) {
+ CompoundTag tag = (CompoundTag) value;
- if (BasketData.isBasket(tag.getString("id"))) {
+ if (BasketData.isBasket(tag.getStringOr("id", ""))) {
return true;
}
- for (String key : tag.getKeySet()) {
- if (hasBasket(tag.getTag(key))) {
+ for (String key : tag.keySet()) {
+ if (hasBasket(tag.get(key))) {
return true;
}
}
- } else if (value instanceof NBTTagList) {
- for (NBTBase child : (NBTTagList) value) {
+ } else if (value instanceof ListTag) {
+ for (Tag child : (ListTag) value) {
if (hasBasket(child)) {
return true;
}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketProbe.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketProbe.java
index 189ffa54..fbd638e4 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketProbe.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketProbe.java
@@ -1,36 +1,36 @@
package zone.moddev.mc.skysbuildingpiecesbop.probe;
+import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.common.MinecraftForge;
-import net.minecraftforge.fml.common.*;
-import net.minecraftforge.fml.common.event.FMLPostInitializationEvent;
+import net.minecraftforge.fml.loading.FMLEnvironment;
+import zone.moddev.mc.skysbuildingpieces.probe.BopServerProbe;
+import net.minecraftforge.fml.ModList;
+import net.minecraftforge.fml.common.Mod;
+import net.minecraftforge.event.server.ServerStartedEvent;
-/** Register after both jars are available to the packaged LaunchWrapper. */
-@Mod(
- modid = "basketsprobe",
- name = "Flower Basket Runtime Probe",
- version = "1",
- dependencies = "required-after:skysbuildingpiecesbop;after:buildingpiecesprobe")
+/** Build-only listeners for real basket windows and copied-world audits. */
+@Mod("basketsprobe")
public final class BasketProbe {
- @Mod.EventHandler
- public void ready(FMLPostInitializationEvent event) throws Exception {
- String phase = System.getProperty("skysbuildingpieces.integrationPhase", "");
-
- if (phase.startsWith("sylvester-baskets-") || phase.startsWith("sylvester-items-")) {
- MinecraftForge.EVENT_BUS
- .unregister(Loader.instance().getIndexedModList().get("buildingpiecesprobe").getMod());
- MinecraftForge.EVENT_BUS.register(
- phase.startsWith("sylvester-items-") ? new RetiredItemWorldChecks() : new BasketWorldChecks());
+ public BasketProbe() {
+ ServerStartedEvent.BUS.addListener(net.minecraftforge.eventbus.api.listener.Priority.LOWEST,
+ this::started);
+ net.minecraftforge.event.TickEvent.ServerTickEvent.Pre.BUS.addListener(
+ net.minecraftforge.eventbus.api.listener.Priority.HIGHEST, BasketPlayerUpgrade::tick);
+ if (FMLEnvironment.dist == Dist.CLIENT) {
+ BasketClientChecks.register();
+ BasketGuiClientChecks.register();
+ BasketDisconnectClientChecks.register();
}
+ }
- MinecraftForge.EVENT_BUS.register(BasketPlayerUpgrade.class);
-
- if (FMLCommonHandler.instance().getSide().isClient()) {
- MinecraftForge.EVENT_BUS
- .register(Class.forName("zone.moddev.mc.skysbuildingpiecesbop.probe.BasketClientChecks"));
- MinecraftForge.EVENT_BUS
- .register(Class.forName("zone.moddev.mc.skysbuildingpiecesbop.probe.BasketGuiClientChecks"));
- MinecraftForge.EVENT_BUS
- .register(Class.forName("zone.moddev.mc.skysbuildingpiecesbop.probe.BasketDisconnectClientChecks"));
+ private void started(ServerStartedEvent event) {
+ String phase = System.getProperty("skysbuildingpieces.integrationPhase", "");
+ if (phase.equals("fresh") || phase.equals("reload")) {
+ BopServerProbe.register(event.getServer(), phase);
+ } else if (phase.startsWith("sylvester-baskets-")) {
+ BasketWorldChecks.register();
+ } else if (phase.startsWith("sylvester-items-")) {
+ RetiredItemWorldChecks.register();
}
}
}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketUpgrade.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketUpgrade.java
deleted file mode 100644
index 08cfc6e3..00000000
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketUpgrade.java
+++ /dev/null
@@ -1,54 +0,0 @@
-package zone.moddev.mc.skysbuildingpiecesbop.probe;
-
-import net.minecraft.world.WorldServer;
-import net.minecraft.item.ItemStack;
-import net.minecraft.tileentity.TileEntityChest;
-import net.minecraft.util.math.BlockPos;
-import zone.moddev.mc.skysbuildingpiecesbop.basket.*;
-
-/** Loads genuine old basket stacks, leaving one old chunk unopened until the next start. */
-public final class BasketUpgrade {
- public static void run(WorldServer world, String phase) throws Exception {
- boolean reload = phase.endsWith("reload");
- int baskets = 0;
- int units = 0;
-
- for (int group = 0; group < (reload ? 2 : 1); group++) {
- int blockX = 299 + 1024 * group;
- int blockZ = -22 + 1024 * group;
-
- world.getChunk(blockX >> 4, blockZ >> 4);
-
- TileEntityChest chest = (TileEntityChest) world.getTileEntity(new BlockPos(blockX, 69, blockZ));
-
- if (chest == null) {
- throw new AssertionError("Basket fixture chest lost at " + blockX + ", " + blockZ + ": "
- + world.getBlockState(new BlockPos(blockX, 69, blockZ)));
- }
- for (int slot = 0; slot < 4; slot++) {
- ItemStack stack = chest.getStackInSlot(slot);
-
- if (stack.getItem() != Baskets.ITEM) {
- throw new AssertionError("Basket fixture item identity: " + stack);
- }
-
- BasketInventory inventory = new BasketInventory(stack);
-
- baskets++;
-
- for (int entryIndex = 0; entryIndex < 18; entryIndex++) {
- units += inventory.getStackInSlot(entryIndex).getCount();
- }
- }
- }
-
- int expected = reload ? 1500 : 687;
-
- if (units != expected) {
- throw new AssertionError("Basket fixture units: " + units + " expected " + expected);
- }
-
- System.out.println("BOP_BASKET_UPGRADE_PASS phase=" + phase + " baskets=" + baskets + " units=" + units
- + " delayed_chunk=" + reload);
- }
-}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketWorldChecks.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketWorldChecks.java
index f5fd7259..a59a2838 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketWorldChecks.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BasketWorldChecks.java
@@ -1,31 +1,47 @@
package zone.moddev.mc.skysbuildingpiecesbop.probe;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.storage.LevelResource;
+
import java.io.*;
import java.nio.file.*;
import net.minecraft.nbt.*;
-import net.minecraft.world.WorldServer;
-import net.minecraftforge.fml.common.FMLCommonHandler;
-import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
-import net.minecraftforge.fml.common.gameevent.TickEvent;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraftforge.server.ServerLifecycleHooks;
+import net.minecraftforge.event.TickEvent;
import zone.moddev.mc.skysbuildingpieces.legacy.*;
/** Loads every original basket carrier in a fresh full-world copy, without visiting unrelated chunks. */
public final class BasketWorldChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
private int ticks;
- @SubscribeEvent
- public void tick(TickEvent.ServerTickEvent event) throws Exception {
- if (event.phase != TickEvent.Phase.END) {
- return;
- }
+ private net.minecraftforge.eventbus.api.listener.EventListener listener;
+
+ static void register() {
+ var check = new BasketWorldChecks();
+ check.listener = TickEvent.ServerTickEvent.Post.BUS.addListener(event -> {
+ try {
+ check.tick(event);
+ } catch (Exception failure) {
+ throw new IllegalStateException("Cannot audit the copied world", failure);
+ }
+ });
+ }
+
+ public void tick(TickEvent.ServerTickEvent.Post event) throws Exception {
- net.minecraft.server.MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance();
+ net.minecraft.server.MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
- if (server == null || server.getWorld(0) == null) {
+ if (server == null || server.getLevel(net.minecraft.world.level.Level.OVERWORLD) == null) {
return;
}
- WorldServer world = server.getWorld(0);
+ ServerLevel world = server.getLevel(net.minecraft.world.level.Level.OVERWORLD);
if (ticks++ == 0) {
for (String row : Files.readAllLines(Paths.get(System.getProperty("skysbuildingpieces.basketIndex")),
@@ -41,15 +57,15 @@ public void tick(TickEvent.ServerTickEvent event) throws Exception {
LegacyBridge.report(world);
- NBTTagCompound state = PiecesWorldState.get(world).writeToNBT(new NBTTagCompound());
- File snapshot = new File(world.getSaveHandler().getWorldDirectory(), "basket-qualification-state.dat");
+ CompoundTag state = PiecesWorldState.get(world).saveTag(new CompoundTag());
+ File snapshot = new File(world.getServer().getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toFile(), "basket-qualification-state.dat");
boolean reload = System.getProperty("skysbuildingpieces.integrationPhase").endsWith("reload");
if (reload) {
- NBTTagCompound before;
+ CompoundTag before;
try (InputStream input = new FileInputStream(snapshot)) {
- before = CompressedStreamTools.readCompressed(input);
+ before = NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap());
}
if (!state.equals(before)) {
throw new AssertionError(
@@ -57,13 +73,13 @@ public void tick(TickEvent.ServerTickEvent event) throws Exception {
}
} else {
try (OutputStream output = new FileOutputStream(snapshot)) {
- CompressedStreamTools.writeCompressed(state, output);
+ NbtIo.writeCompressed(state, output);
}
}
- System.out.println("BOP_BASKET_SYLVESTER_PASS all_carriers_loaded=true schema="
- + state.getInteger("schema_version") + " reload=" + reload);
- net.minecraftforge.common.MinecraftForge.EVENT_BUS.unregister(this);
- server.initiateShutdown();
+ LOGGER.info("BOP_BASKET_SYLVESTER_PASS all_carriers_loaded=true schema=" + state.getIntOr("schema_version", 0)
+ + " reload=" + reload);
+ TickEvent.ServerTickEvent.Post.BUS.removeListener(listener);
+ server.halt(false);
}
}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopChecks.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopChecks.java
index c745da7e..01e33a8a 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopChecks.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopChecks.java
@@ -1,455 +1,18 @@
package zone.moddev.mc.skysbuildingpiecesbop.probe;
-import net.minecraft.block.Block;
-import net.minecraft.block.state.IBlockState;
-import net.minecraft.item.ItemStack;
-import net.minecraft.nbt.*;
-import net.minecraft.util.EnumFacing;
-import net.minecraft.util.math.BlockPos;
-import net.minecraft.world.WorldServer;
-import zone.moddev.mc.skysbuildingpieces.SkysBuildingPieces;
-import zone.moddev.mc.skysbuildingpieces.catalogue.*;
-import zone.moddev.mc.skysbuildingpieces.content.*;
-import zone.moddev.mc.skysbuildingpieces.legacy.*;
+import net.minecraft.server.level.ServerLevel;
-/** Real BOP physics, early raw migration and independent coverage; build-only. */
+/** Run the add-on's native regressions after the shared placement and recipe checks. */
public final class BopChecks {
- public static void run(WorldServer world) {
- BasketChecks.run(world);
- RetiredItemChecks.run(world);
- RetiredDecorationChecks.run(world);
- retiredFullRocks();
- require(Catalogue.INSTANCE.modules.get("biomesoplenty").palettes.size() == 66, "BOP ID budget");
-
- String[] oldRocks = { "crag_rock", "limestone", "limestone_polished", "shale", "shale_polished", "siltstone",
- "siltstone_polished" };
-
- for (int slot = 0; slot < oldRocks.length; slot++) {
- IBlockState full = Pieces.nativeState(Catalogue.INSTANCE.materials.get("biomesoplenty:" + oldRocks[slot]),
- "full");
- Block nativeRock = Block
- .getBlockFromName("mineralogy:" + zone.moddev.mc.skysbuildingpiecesbop.RetiredRocks.NAMES[slot]);
-
- if (nativeRock instanceof net.minecraft.block.BlockAir) {
- nativeRock = null;
- }
-
- require(full.getBlock() == (nativeRock == null ? zone.moddev.mc.skysbuildingpiecesbop.RetiredRocks.BLOCK
- : nativeRock), "retired material target " + oldRocks[slot]);
-
- if (nativeRock == null) {
- require(full.getBlock().getMetaFromState(full) == slot, "fixed retired-material slot");
- }
- }
-
- int pathPalettes = Catalogue.INSTANCE.materials.containsKey("minecraft:grass_path") ? 2 : 0;
- require(Catalogue.INSTANCE.modules.get("vanilla").palettes.size() == 235 + pathPalettes, "core palette budget");
-
- int registryCount = 0;
- int highest = 0;
-
- for (Block block : Block.REGISTRY) {
- registryCount++;
- highest = Math.max(highest, Block.getIdFromBlock(block));
- }
-
- require(highest < 4096, "legacy registry ceiling");
- System.out.println("BOP_REGISTRY_BUDGET registered=" + registryCount + " highest=" + highest + " free="
- + (4096 - registryCount));
-
- BlockPos pos = new BlockPos(-700, 90, -700);
- int checked = 0;
-
- for (Catalogue.MaterialDef materialDefinition : Catalogue.INSTANCE.modules.get("biomesoplenty").materials
- .values()) {
- for (Shape shape : new Shape[] { Shape.SLAB, Shape.VERTICAL_SLAB, Shape.HORIZONTAL_STEP,
- Shape.VERTICAL_STEP, Shape.CORNER, Shape.STAIRS, Shape.WALL }) {
- for (int orientation = 0; orientation < shape.states; orientation++) {
- IBlockState target = Pieces.state(materialDefinition.id, shape, orientation);
-
- if (target == null) {
- continue;
- }
-
- require(Pieces.stack(target, 1) != null, "registered item");
-
- if (target.getBlock() instanceof PieceBlock) {
- PieceBlock pieceBlock = (PieceBlock) target.getBlock();
-
- world.setBlockState(pos, target, 2);
-
- IBlockState full = Pieces.nativeState(materialDefinition, "full");
- float hard = pieceBlock.getBlockHardness(target, world, pos);
- float blast = pieceBlock.getExplosionResistance(world, pos, null, null);
- int fire = pieceBlock.getFlammability(world, pos, EnumFacing.UP);
- int spread = pieceBlock.getFireSpreadSpeed(world, pos, EnumFacing.UP);
-
- world.setBlockState(pos, full, 2);
- require(hard == full.getBlock().getBlockHardness(full, world, pos),
- "native hardness " + materialDefinition.id);
- require(blast == full.getBlock().getExplosionResistance(world, pos, null, null),
- "native blast resistance " + materialDefinition.id);
- require(fire == full.getBlock().getFlammability(world, pos, EnumFacing.UP),
- "native flammability " + materialDefinition.id);
- require(spread == full.getBlock().getFireSpreadSpeed(world, pos, EnumFacing.UP),
- "native spread " + materialDefinition.id);
- require(target.getMaterial() == full.getMaterial(),
- "native block material " + materialDefinition.id);
- }
-
- checked++;
- }
- }
- }
-
- world.setBlockToAir(pos);
-
- Block legacyBlock = Block.getBlockFromName("buildingbricks:rock_step");
-
- require(legacyBlock != null, "legacy registry probe");
-
- boolean installed = net.minecraftforge.fml.common.Loader.isModLoaded("buildingbricks");
- boolean forced = SkysBuildingPieces.forceReplace;
-
- try {
- if (installed) {
- SkysBuildingPieces.forceReplace = false;
-
- NBTTagCompound off = chunk(legacyBlock, 0, "biomesoplenty:limestone");
- NBTTagCompound copy = off.copy();
-
- LegacyBridge.prepareChunk(world, off);
- require(copy.equals(off), "replacement off does not mark or migrate");
- inventories(world, false);
- SkysBuildingPieces.forceReplace = true;
- inventories(world, true);
- }
- for (int meta = 0; meta < 16; meta++) {
- NBTTagCompound raw = chunk(legacyBlock, meta, "biomesoplenty:limestone");
- NBTTagCompound level = raw.getCompoundTag("Level");
- NBTTagCompound coverage = new NBTTagCompound();
-
- coverage.setInteger("vanilla", 1);
- coverage.setInteger("later_addon", 7);
- level.setTag("skysbuildingpieces_coverage", coverage);
- LegacyBridge.prepareChunk(world, raw);
-
- NBTTagCompound section = level.getTagList("Sections", 10).getCompoundTagAt(0);
- int blockId = (section.getByteArray("Blocks")[0] & 255)
- | (LegacyBridge.nibble(section.getByteArray("Add"), 0) << 8);
- IBlockState actual = Block.getBlockById(blockId)
- .getStateFromMeta(LegacyBridge.nibble(section.getByteArray("Data"), 0));
- Shape shape = LegacyMapping.shape("buildingbricks:rock_step", meta);
-
- require(actual
- .equals(Pieces.state("biomesoplenty:limestone", shape, LegacyMapping.orientation(shape, meta))),
- "BOP legacy geometry " + meta);
- require(coverage.getInteger("vanilla") == 1 && coverage.getInteger("biomesoplenty") == 1
- && coverage.getInteger("later_addon") == 7, "independent markers");
- require(!raw.hasKey("skysbuildingpieces_delta")
- && raw.getCompoundTag("skysbuildingpieces_module_delta").getLong("biomesoplenty") == 1,
- "BOP only delta");
- require(level.getTagList("TileEntities", 10).tagCount() == 0
- && coverage.getTagList("retained_tiles", 10).tagCount() == 1,
- "successful tile removal and retention");
-
- NBTTagCompound before = raw.copy();
-
- LegacyBridge.prepareChunk(world, raw);
- require(before.equals(raw), "chunk recovery idempotent");
- }
-
- NBTTagCompound raw = new NBTTagCompound();
- NBTTagCompound tag = new NBTTagCompound();
-
- raw.setString("id", "buildingbricks:rock_step");
- raw.setByte("Count", (byte) 17);
- raw.setShort("Damage", (short) 30000);
- tag.setString("material", "biomesoplenty:limestone");
- tag.setString("custom", "unchanged");
- raw.setTag("tag", tag);
- LegacyBridge.prepareStack(raw);
-
- ItemStack stack = new ItemStack(raw);
-
- require(stack != null && stack.getCount() == 17 && stack.getTagCompound().equals(tag),
- "item count and unrelated NBT");
- require(stack.getItem().getRegistryName().getNamespace().equals("skysbuildingpiecesbop"), "item owner");
-
- NBTTagCompound before = raw.copy();
-
- LegacyBridge.prepareStack(raw);
- require(before.equals(raw), "item recovery idempotent");
-
- PiecesWorldState state = new PiecesWorldState();
- NBTTagCompound saved = new NBTTagCompound();
-
- saved.setInteger("schema_version", 1);
- saved.setLong("vanilla_blocks", 6062);
- saved.setLong("vanilla_items", 1170);
- saved.setLong("vanilla_chunks", 193);
- saved.setString("future_field", "preserved");
- state.readFromNBT(saved);
- state.addChunk("biomesoplenty", 601);
- state.addItems("biomesoplenty", 52);
-
- NBTTagCompound result = state.writeToNBT(new NBTTagCompound());
-
- require(result.getLong("vanilla_blocks") == 6062 && result.getLong("vanilla_items") == 1170
- && result.getString("future_field").equals("preserved"), "historical vanilla state unchanged");
-
- PiecesWorldState reloaded = new PiecesWorldState();
-
- reloaded.readFromNBT(result);
- require(reloaded.modules.get("biomesoplenty").blocks == 601
- && reloaded.modules.get("biomesoplenty").items == 52, "module state continuity");
- } finally {
- SkysBuildingPieces.forceReplace = forced;
- }
-
- System.out.println("BOP_RUNTIME_PASS states=" + checked + " legacy_step_codes=16");
- }
-
- private static void retiredFullRocks() {
- String[] ids = { "biomesoplenty:stone", "biomesoplenty:crag_rock" };
- Block[] holders = { zone.moddev.mc.skysbuildingpiecesbop.RetiredMaterialHolders.STONE,
- zone.moddev.mc.skysbuildingpiecesbop.RetiredMaterialHolders.CRAG };
- int checked = 0;
-
- for (int type = 0; type < ids.length; type++) {
- for (int meta : type == 0 ? new int[] { 0, 1, 2, 8, 9, 10 } : new int[] { 0 }) {
- IBlockState target = HistoricalFullBlocks.resolve(ids[type], meta);
- NBTTagCompound raw = chunk(holders[type], meta, "");
- NBTTagCompound level = raw.getCompoundTag("Level");
- NBTTagCompound coverage = new NBTTagCompound();
-
- level.setTag("TileEntities", new NBTTagList());
- coverage.setInteger("vanilla", 1);
- coverage.setInteger("biomesoplenty", 1);
- coverage.setInteger("future", 9);
- level.setTag("skysbuildingpieces_coverage", coverage);
-
- java.util.Map snapshot = java.util.Collections
- .singletonMap(Block.getIdFromBlock(holders[type]), ids[type]);
-
- HistoricalFullBlocks.prepareChunk(raw, snapshot);
-
- NBTTagCompound section = level.getTagList("Sections", 10).getCompoundTagAt(0);
- int numeric = (section.getByteArray("Blocks")[0] & 255)
- | (LegacyBridge.nibble(section.getByteArray("Add"), 0) << 8);
-
- require(Block.getBlockById(numeric)
- .getStateFromMeta(LegacyBridge.nibble(section.getByteArray("Data"), 0)).equals(target),
- "retired full-rock block identity " + ids[type] + " / " + meta);
- require(coverage.getInteger("vanilla") == 1 && coverage.getInteger("biomesoplenty") == 1
- && coverage.getInteger("future") == 9 && coverage.getInteger(HistoricalFullBlocks.MODULE) == 1,
- "retired recovery keeps earlier markers");
-
- NBTTagCompound once = raw.copy();
-
- HistoricalFullBlocks.prepareChunk(raw, snapshot);
- require(raw.equals(once), "retired full-rock reload is idempotent");
-
- NBTTagCompound stack = new NBTTagCompound();
- NBTTagCompound tag = new NBTTagCompound();
-
- stack.setString("id", ids[type]);
- stack.setShort("Damage", (short) meta);
- stack.setByte("Count", (byte) 37);
- tag.setString("custom_label", "Old full rock");
- tag.setInteger("RepairCost", 12);
- stack.setTag("tag", tag);
- LegacyBridge.prepareStack(stack);
-
- ItemStack item = new ItemStack(stack);
-
- require(item.getCount() == 37 && item.getTagCompound().equals(tag)
- && item.getItem() == net.minecraft.item.Item.getItemFromBlock(target.getBlock())
- && item.getMetadata() == target.getBlock().getMetaFromState(target),
- "retired full-rock item count and complete NBT");
- once = stack.copy();
- LegacyBridge.prepareStack(stack);
- require(stack.equals(once), "retired full-rock item reload is idempotent");
- ++checked;
- }
- }
-
- boolean blocked = false;
-
- try {
- HistoricalFullBlocks.resolve("biomesoplenty:stone", 3);
- } catch (LegacyBridge.RecoveryAbort expected) {
- blocked = true;
- }
-
- require(blocked, "unknown retired full-rock slot stops safely");
- System.out.println("BOP_RETIRED_FULL_ROCKS_PASS variants=" + checked + " unknown=blocked NBT=preserved");
- }
-
- private static ItemStack legacy(int count) {
- ItemStack stack = new ItemStack(net.minecraft.item.Item.getByNameOrId("buildingbricks:rock_step"), count,
- 30000);
- NBTTagCompound tag = new NBTTagCompound();
-
- tag.setString("material", "biomesoplenty:limestone");
- tag.setString("custom_label", "unchanged");
-
- NBTTagList enchantments = new NBTTagList();
- NBTTagCompound enchantment = new NBTTagCompound();
-
- enchantment.setShort("id", (short) 33);
- enchantment.setShort("lvl", (short) 2);
- enchantments.appendTag(enchantment);
- tag.setTag("ench", enchantments);
- stack.setTagCompound(tag);
- return stack;
+ private BopChecks() {
}
- private static void identity(ItemStack stack, int count, boolean convert) {
- require(stack != null && stack.getCount() == count, "BOP inventory count");
- require(stack.getItem().getRegistryName().getNamespace()
- .equals(convert ? "skysbuildingpiecesbop" : "buildingbricks"), "BOP inventory owner");
- require(stack.getTagCompound().equals(legacy(1).getTagCompound()), "BOP complete inventory NBT");
- }
-
- private static void inventories(WorldServer world, boolean convert) {
- try {
- net.minecraft.entity.player.EntityPlayerMP player = net.minecraftforge.common.util.FakePlayerFactory
- .getMinecraft(world);
-
- player.inventory.setInventorySlotContents(0, legacy(13));
- player.getInventoryEnderChest().setInventorySlotContents(0, legacy(11));
- net.minecraftforge.common.MinecraftForge.EVENT_BUS
- .post(new net.minecraftforge.event.entity.EntityJoinWorldEvent(player, world));
- identity(player.inventory.getStackInSlot(0), 13, convert);
- identity(player.getInventoryEnderChest().getStackInSlot(0), 11, convert);
- player.inventory.setInventorySlotContents(1, legacy(9));
- net.minecraftforge.common.MinecraftForge.EVENT_BUS
- .post(new net.minecraftforge.event.entity.player.PlayerEvent.LoadFromFile(player,
- world.getSaveHandler().getWorldDirectory(), player.getUniqueID().toString()));
- identity(player.inventory.getStackInSlot(1), 9, convert);
-
- BlockPos pos = new BlockPos(934, 70, 936);
-
- world.getChunk(pos);
-
- net.minecraft.entity.item.EntityItem drop = new net.minecraft.entity.item.EntityItem(world, 934, 70, 936,
- legacy(5));
-
- require(world.spawnEntity(drop), "BOP dropped entity join");
- identity(drop.getItem(), 5, convert);
- world.setBlockState(pos.north(), net.minecraft.init.Blocks.STONE.getDefaultState(), 2);
-
- net.minecraft.entity.item.EntityItemFrame frame = new net.minecraft.entity.item.EntityItemFrame(world, pos,
- EnumFacing.SOUTH);
-
- frame.setDisplayedItem(legacy(1));
- world.spawnEntity(frame);
- identity(frame.getDisplayedItem(), 1, convert);
-
- zone.moddev.mc.skysbuildingpieces.probe.CompatibilityChecks.HandlerTile handler = new zone.moddev.mc.skysbuildingpieces.probe.CompatibilityChecks.HandlerTile();
-
- handler.setWorld(world);
-
- net.minecraftforge.items.IItemHandlerModifiable items = (net.minecraftforge.items.IItemHandlerModifiable) handler
- .getCapability(net.minecraftforge.items.CapabilityItemHandler.ITEM_HANDLER_CAPABILITY, null);
-
- items.setStackInSlot(0, legacy(17));
-
- java.lang.reflect.Method scan = MigrationEvents.class.getDeclaredMethod("containers",
- net.minecraft.tileentity.TileEntity.class);
-
- scan.setAccessible(true);
- scan.invoke(null, handler);
- identity(items.getStackInSlot(0), 17, convert);
-
- ItemStack first = items.getStackInSlot(0);
-
- scan.invoke(null, handler);
- require(items.getStackInSlot(0) == first, "BOP handler duplicate-face idempotence");
- world.setBlockState(pos, net.minecraft.init.Blocks.CHEST.getDefaultState(), 2);
-
- net.minecraft.tileentity.TileEntityChest chest = (net.minecraft.tileentity.TileEntityChest) world
- .getTileEntity(pos);
-
- chest.setInventorySlotContents(0, legacy(7));
- scan.invoke(null, chest);
- identity(chest.getStackInSlot(0), 7, convert);
-
- IBlockState existingState = Block.getBlockFromName("buildingbricks:rock_step").getStateFromMeta(11);
-
- world.setBlockState(pos, existingState, 2);
-
- net.minecraft.tileentity.TileEntity tile = world.getTileEntity(pos);
- NBTTagCompound raw = tile.writeToNBT(new NBTTagCompound());
-
- raw.setString("material", "biomesoplenty:limestone");
- tile.readFromNBT(raw);
- net.minecraftforge.common.MinecraftForge.EVENT_BUS
- .post(new net.minecraftforge.event.world.BlockEvent.PlaceEvent(
- net.minecraftforge.common.util.BlockSnapshot.getBlockSnapshot(world, pos),
- net.minecraft.init.Blocks.STONE.getDefaultState(), player,
- net.minecraft.util.EnumHand.MAIN_HAND));
- require(world.getBlockState(pos).getBlock().getRegistryName().getNamespace()
- .equals(convert ? "skysbuildingpiecesbop" : "buildingbricks"), "BOP placed replacement gating");
- require((world.getTileEntity(pos) == null) == convert, "BOP material tile removed only after conversion");
- drop.setDead();
- frame.setDead();
- world.setBlockToAir(pos);
- world.setBlockToAir(pos.north());
- player.inventory.clear();
- player.getInventoryEnderChest().clear();
- System.out.println("BOP_INVENTORY_PASS force=" + convert);
- } catch (Exception failure) {
- throw new AssertionError("BOP live inventory checks", failure);
- }
- }
-
- private static NBTTagCompound chunk(Block legacyBlock, int meta, String material) {
- NBTTagCompound root = new NBTTagCompound();
- NBTTagCompound level = new NBTTagCompound();
- NBTTagCompound section = new NBTTagCompound();
-
- root.setTag("Level", level);
-
- byte[] blockIdLowBytes = new byte[4096];
- byte[] high = new byte[2048];
- byte[] data = new byte[2048];
-
- int blockId = Block.getIdFromBlock(legacyBlock);
-
- blockIdLowBytes[0] = (byte) blockId;
- LegacyBridge.setNibble(high, 0, blockId >> 8);
- LegacyBridge.setNibble(data, 0, meta);
- section.setByte("Y", (byte) 4);
- section.setByteArray("Blocks", blockIdLowBytes);
- section.setByteArray("Add", high);
- section.setByteArray("Data", data);
-
- NBTTagList sections = new NBTTagList();
-
- sections.appendTag(section);
- level.setTag("Sections", sections);
-
- NBTTagCompound tile = new NBTTagCompound();
-
- tile.setString("id", "buildingbricks:material");
- tile.setString("material", material);
- tile.setInteger("x", 0);
- tile.setInteger("y", 64);
- tile.setInteger("z", 0);
- tile.setString("unrelated", "retained");
-
- NBTTagList tiles = new NBTTagList();
-
- tiles.appendTag(tile);
- level.setTag("TileEntities", tiles);
- return root;
- }
-
- private static void require(boolean value, String message) {
- if (!value) {
- throw new AssertionError(message);
- }
+ public static void run(ServerLevel world) throws Exception {
+ BasketChecks.run(world);
+ RetiredItemChecks.run(world);
+ PlantRecoveryChecks.run();
+ DecorationChecks.run(world);
+ MaterialChecks.run(world);
+ zone.moddev.mc.skysbuildingpieces.probe.BopMigrationChecks.run(world);
}
}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopLegacyFixture.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopLegacyFixture.java
deleted file mode 100644
index 1178591d..00000000
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopLegacyFixture.java
+++ /dev/null
@@ -1,169 +0,0 @@
-package zone.moddev.mc.skysbuildingpiecesbop.probe;
-
-import net.minecraft.block.Block;
-import net.minecraft.block.state.IBlockState;
-import net.minecraft.entity.item.EntityItem;
-import net.minecraft.init.Blocks;
-import net.minecraft.item.ItemStack;
-import net.minecraft.nbt.NBTTagCompound;
-import net.minecraft.tileentity.TileEntity;
-import net.minecraft.tileentity.TileEntityChest;
-import net.minecraft.util.math.BlockPos;
-import net.minecraft.world.WorldServer;
-import zone.moddev.mc.skysbuildingpieces.content.Pieces;
-import zone.moddev.mc.skysbuildingpieces.legacy.*;
-
-/** Genuine mixed-material save, with no reliance on dynamic item damage. */
-public final class BopLegacyFixture {
- private static final String[] IDS = { "wood_vertical_slab", "wood_corner", "rock_wall", "metal_stairs", "wood_slab",
- "rock_slab" };
- private static final String[] MATERIALS = { "biomesoplenty:planks_cherry", "biomesoplenty:planks_hellbark",
- "biomesoplenty:limestone", "biomesoplenty:amethyst_block", "biomesoplenty:planks_ebony",
- "biomesoplenty:crag_rock" };
-
- private static BlockPos pos(int entryIndex) {
- return new BlockPos(800 + (entryIndex & 15), 70, 800 + (entryIndex >> 4));
- }
-
- private static String id(int entryIndex) {
- return entryIndex < 32 ? "rock_step" : IDS[entryIndex - 32];
- }
-
- private static String material(int entryIndex) {
- return entryIndex < 16 ? "minecraft:stone"
- : entryIndex < 32 ? "biomesoplenty:limestone" : MATERIALS[entryIndex - 32];
- }
-
- private static int meta(int entryIndex) {
- return entryIndex < 32 ? entryIndex & 15
- : entryIndex == 32 ? 3 : entryIndex == 33 ? 7 : entryIndex == 35 ? 6 : entryIndex == 36 ? 1 : 0;
- }
-
- public static void run(WorldServer world, String phase) throws Exception {
- boolean source = phase.equals("bop-legacy-source") || phase.equals("bop-legacy-unknown-source");
- boolean converted = LegacyBridge.active();
-
- if (source) {
- require(!converted, "source replacement must be disabled");
-
- for (int entryIndex = 0; entryIndex < 38; entryIndex++) {
- Block legacyBlock = Block.getBlockFromName("buildingbricks:" + id(entryIndex));
-
- require(legacyBlock != null, "real legacy block " + id(entryIndex));
- world.setBlockState(pos(entryIndex), legacyBlock.getStateFromMeta(meta(entryIndex)), 2);
-
- TileEntity tile = world.getTileEntity(pos(entryIndex));
-
- require(tile != null, "real legacy material tile");
-
- NBTTagCompound raw = tile.writeToNBT(new NBTTagCompound());
-
- raw.setString("material", material(entryIndex));
- raw.setString("fixture_note", "preserve unrelated tile data");
- tile.readFromNBT(raw);
- tile.markDirty();
- }
-
- world.setBlockState(pos(40), Blocks.CHEST.getDefaultState(), 2);
-
- TileEntityChest chest = (TileEntityChest) world.getTileEntity(pos(40));
-
- chest.setInventorySlotContents(0, stack("minecraft:stone", 7));
- chest.setInventorySlotContents(1, stack("biomesoplenty:limestone", 47));
- world.spawnEntity(new EntityItem(world, 804, 72, 804, stack("biomesoplenty:limestone", 5)));
-
- if (phase.equals("bop-legacy-unknown-source")) {
- TileEntity tile = world.getTileEntity(pos(0));
- NBTTagCompound raw = tile.writeToNBT(new NBTTagCompound());
-
- raw.setString("material", "missing:unknown");
- tile.readFromNBT(raw);
- tile.markDirty();
- }
- } else {
- for (int entryIndex = 0; entryIndex < 38; entryIndex++) {
- IBlockState actual = world.getBlockState(pos(entryIndex));
-
- if (converted) {
- ShapePair pair = new ShapePair(id(entryIndex), meta(entryIndex));
- IBlockState expected = Pieces.state(material(entryIndex), pair.shape, pair.orientation);
-
- require(actual.equals(expected),
- "identity and orientation " + entryIndex + " " + actual + " != " + expected);
- require(world.getTileEntity(pos(entryIndex)) == null,
- "old material tile removed only after conversion");
- } else {
- require(actual.getBlock().getRegistryName().toString().equals("buildingbricks:" + id(entryIndex)),
- "replacement off identity");
- require(world.getTileEntity(pos(entryIndex)).writeToNBT(new NBTTagCompound()).getString("material")
- .equals(material(entryIndex)), "retained material");
- }
- }
-
- TileEntityChest chest = (TileEntityChest) world.getTileEntity(pos(40));
-
- for (int slot = 0; slot < 2; slot++) {
- ItemStack actual = chest.getStackInSlot(slot);
-
- require(actual != null && actual.getCount() == (slot == 0 ? 7 : 47), "container count");
- require(actual.getTagCompound().equals(tag(slot == 0 ? "minecraft:stone" : "biomesoplenty:limestone")),
- "complete item NBT");
- require(actual.getItem().getRegistryName().getNamespace().equals(
- converted ? (slot == 0 ? "skysbuildingpieces" : "skysbuildingpiecesbop") : "buildingbricks"),
- "module item identity");
- }
- if (converted) {
- LegacyBridge.report(world);
-
- PiecesWorldState state = PiecesWorldState.get(world);
-
- require(state.blocks == 16 && state.items == 7,
- "vanilla accounting " + state.blocks + " / " + state.items);
-
- PiecesWorldState.Totals bop = state.modules.get("biomesoplenty");
-
- require(bop != null && bop.blocks == 22 && bop.items == 52, "BOP accounting");
- } else {
- require(!new java.io.File(world.getSaveHandler().getWorldDirectory(),
- "data/skysbuildingpieces_world_state.dat").exists(), "replacement off no state");
- }
- }
-
- System.out.println("BUILDING_PIECES_LEGACY_PASS " + phase + " vanilla=16 BOP=22 items=7/52");
- }
-
- private static ItemStack stack(String material, int count) {
- NBTTagCompound raw = new NBTTagCompound();
-
- raw.setString("id", "buildingbricks:rock_step");
- raw.setByte("Count", (byte) count);
- raw.setShort("Damage", (short) 32767);
- raw.setTag("tag", tag(material));
- return new ItemStack(raw);
- }
-
- private static NBTTagCompound tag(String material) {
- NBTTagCompound tag = new NBTTagCompound();
-
- tag.setString("material", material);
- tag.setString("custom_label", "complete NBT survives");
- tag.setInteger("RepairCost", 9);
- return tag;
- }
-
- private static final class ShapePair {
- final zone.moddev.mc.skysbuildingpieces.catalogue.Shape shape;
- final int orientation;
-
- ShapePair(String registryId, int meta) {
- shape = LegacyMapping.shape("buildingbricks:" + registryId, meta);
- orientation = LegacyMapping.orientation(shape, meta);
- }
- }
-
- private static void require(boolean value, String message) {
- if (!value) {
- throw new AssertionError(message);
- }
- }
-}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopProbeItems.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopProbeItems.java
new file mode 100644
index 00000000..679a8b16
--- /dev/null
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/BopProbeItems.java
@@ -0,0 +1,101 @@
+package zone.moddev.mc.skysbuildingpiecesbop.probe;
+
+import com.mojang.authlib.GameProfile;
+import java.util.UUID;
+import net.minecraft.core.HolderLookup;
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ClientInformation;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.item.ItemStack;
+import net.minecraftforge.registries.ForgeRegistries;
+import net.minecraftforge.server.ServerLifecycleHooks;
+import zone.moddev.mc.skysbuildingpieces.probe.NativeProbeItems;
+
+public final class BopProbeItems {
+ static net.minecraft.world.level.block.state.BlockState readSavedBlockState(net.minecraft.nbt.Tag saved) {
+ CompoundTag state;
+ if (saved instanceof net.minecraft.nbt.StringTag name) {
+ state = new CompoundTag();
+ state.putString("id", name.value());
+ } else {
+ state = zone.moddev.mc.skysbuildingpieces.probe.legacy.LegacyTestScope.nativeStateTag((CompoundTag) saved);
+ }
+ return net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK, state);
+ }
+
+ static CompoundTag upgradeChunk(net.minecraft.world.level.chunk.storage.SimpleRegionStorage storage,
+ CompoundTag source) {
+ int targetVersion = net.minecraft.SharedConstants.getCurrentVersion().dataVersion().version();
+ return storage.upgradeChunkTag(source.copy(), 0, overworldUpgradeContext(), targetVersion);
+ }
+
+ static CompoundTag overworldUpgradeContext() {
+ CompoundTag context = new CompoundTag();
+ context.putString("dimension", "minecraft:overworld");
+ context.putString("generator", "minecraft:noise");
+ return context;
+ }
+
+ static void loadPlayer(ServerPlayer player) {
+ var data = player.level().getServer().getPlayerList().getPlayerIo()
+ .load(new net.minecraft.server.players.NameAndId(player.getGameProfile()))
+ .orElseThrow(() -> new AssertionError("Saved player data is missing"));
+ player.load(net.minecraft.world.level.storage.TagValueInput.create(
+ net.minecraft.util.ProblemReporter.DISCARDING, player.registryAccess(), data));
+ }
+
+ static net.minecraft.network.chat.Component readSavedText(String json, ServerLevel world) {
+ return net.minecraft.network.chat.ComponentSerialization.CODEC.parse(
+ world.registryAccess().createSerializationContext(com.mojang.serialization.JsonOps.INSTANCE),
+ com.google.gson.JsonParser.parseString(json)).getOrThrow();
+ }
+
+ private BopProbeItems() {
+ }
+
+ public static HolderLookup.Provider registries() {
+ return ServerLifecycleHooks.getCurrentServer().registryAccess();
+ }
+
+ public static ServerPlayer player(ServerLevel world) {
+ return player(world, new GameProfile(UUID.fromString("e55e930f-3729-4b29-b92f-902046f457e1"), "BasketProbe"));
+ }
+
+ public static ServerPlayer player(ServerLevel world, GameProfile profile) {
+ return new ServerPlayer(world.getServer(), world, profile, ClientInformation.createDefault());
+ }
+
+ public static CompoundTag legacy(ItemStack stack) {
+ CompoundTag saved = new CompoundTag();
+ saved.putString("id", ForgeRegistries.ITEMS.getKey(stack.getItem()).toString());
+ saved.putByte("Count", (byte) stack.getCount());
+ return saved;
+ }
+
+ public static CompoundTag save(ItemStack stack) {
+ return NativeProbeItems.save(stack);
+ }
+
+ public static ItemStack read(CompoundTag saved) {
+ return NativeProbeItems.read(saved);
+ }
+
+ public static void name(ItemStack stack, String name) {
+ stack.set(DataComponents.CUSTOM_NAME, Component.literal(name));
+ }
+
+ public static void setCustom(ItemStack stack, CompoundTag data) {
+ NativeProbeItems.setCustom(stack, data);
+ }
+
+ public static CompoundTag custom(ItemStack stack) {
+ return NativeProbeItems.custom(stack);
+ }
+
+ public static boolean hasCustom(ItemStack stack) {
+ return NativeProbeItems.hasCustom(stack);
+ }
+}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredDecorationChecks.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredDecorationChecks.java
deleted file mode 100644
index 53897bf8..00000000
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredDecorationChecks.java
+++ /dev/null
@@ -1,214 +0,0 @@
-package zone.moddev.mc.skysbuildingpiecesbop.probe;
-
-import java.util.*;
-import net.minecraft.block.Block;
-import net.minecraft.block.state.IBlockState;
-import net.minecraft.init.Blocks;
-import net.minecraft.item.ItemStack;
-import net.minecraft.nbt.*;
-import net.minecraft.util.math.BlockPos;
-import net.minecraft.world.WorldServer;
-import zone.moddev.mc.skysbuildingpieces.legacy.*;
-import zone.moddev.mc.skysbuildingpiecesbop.*;
-
-/** Real registry, storage, physics and early recovery checks for removed BOP decorations. */
-public final class RetiredDecorationChecks {
- public static void run(WorldServer world) {
- LegacyBridge.report(world);
-
- PiecesWorldState counters = PiecesWorldState.get(world);
- NBTTagCompound previous = counters.writeToNBT(new NBTTagCompound());
- BlockPos pos = new BlockPos(930, 90, 930);
- IBlockState before = world.getBlockState(pos);
- IBlockState above = world.getBlockState(pos.up());
- IBlockState below = world.getBlockState(pos.down());
-
- try {
- String[] names = { "stone_formations", "jelled_poison", "tree_moss" };
- Block[] holders = { RetiredMaterialHolders.FORMATIONS, RetiredMaterialHolders.POISON,
- RetiredMaterialHolders.MOSS };
-
- for (int type = 0; type < names.length; type++) {
- for (int meta = 0; meta < (type == 2 ? 16 : 1); meta++) {
- String legacyId = "biomesoplenty:" + names[type];
- IBlockState target = HistoricalFullBlocks.resolve(legacyId, meta);
-
- require(target
- .equals(HistoricalFullBlocks.resolve(holders[type].getRegistryName().toString(), meta)),
- "holder target");
-
- NBTTagCompound root = chunk(holders[type], meta, target);
- NBTTagCompound level = root.getCompoundTag("Level");
- NBTTagCompound coverage = new NBTTagCompound();
-
- coverage.setInteger(HistoricalFullBlocks.MODULE, 1);
- coverage.setInteger("vanilla", 1);
- coverage.setInteger("biomesoplenty", 1);
- coverage.setInteger("future", 9);
- level.setTag("skysbuildingpieces_coverage", coverage);
-
- Map registryIds = Collections.singletonMap(Block.getIdFromBlock(holders[type]),
- legacyId);
-
- HistoricalFullBlocks.prepareChunk(root, registryIds);
-
- NBTTagCompound section = level.getTagList("Sections", 10).getCompoundTagAt(0);
-
- require(read(section, 0).equals(target) && read(section, 1).equals(target),
- "mixed historical and permanent state " + legacyId + "/" + meta);
- require(coverage.getInteger(HistoricalFullBlocks.MODULE) == 1 && coverage.getInteger("future") == 9
- && coverage.getInteger(RetiredDecorations.MODULE) == 1, "independent coverage");
- require(root.getCompoundTag("skysbuildingpieces_historical_delta")
- .getLong(RetiredDecorations.MODULE) == 1, "only one old block counted");
-
- NBTTagCompound once = root.copy();
-
- HistoricalFullBlocks.prepareChunk(root, registryIds);
- require(once.equals(root), "chunk reload idempotence");
-
- NBTTagCompound stack = new NBTTagCompound();
- NBTTagCompound tag = new NBTTagCompound();
-
- stack.setString("id", legacyId);
- stack.setByte("Count", (byte) 37);
- stack.setShort("Damage", (short) meta);
- tag.setString("custom_label", "Preserved decoration");
- tag.setInteger("RepairCost", 12);
- stack.setTag("tag", tag);
- LegacyBridge.prepareStack(stack);
-
- ItemStack actual = new ItemStack(stack);
-
- require(actual.getCount() == 37 && actual.getTagCompound().equals(tag),
- "complete stack count and NBT");
- require(actual.getItem() == net.minecraft.item.Item.getItemFromBlock(target.getBlock()),
- "stack target");
- once = stack.copy();
- LegacyBridge.prepareStack(stack);
- require(once.equals(stack), "stack reload idempotence");
- }
- }
- for (String legacyId : new String[] { "stone_formations", "jelled_poison" }) {
- boolean stopped = false;
-
- try {
- HistoricalFullBlocks.resolve("biomesoplenty:" + legacyId, 1);
- } catch (LegacyBridge.RecoveryAbort expected) {
- stopped = true;
- }
-
- require(stopped, "unassigned metadata stops safely");
- }
-
- IBlockState formation = RetiredDecorations.STONE_FORMATIONS.getDefaultState();
-
- require(!formation.isFullCube() && !formation.isOpaqueCube(), "formation is not a full cube");
- require(formation.getCollisionBoundingBox(world, pos) == null, "formation has no physical collision");
-
- for (net.minecraft.util.EnumFacing face : net.minecraft.util.EnumFacing.values()) {
- require(formation.getBlockFaceShape(world, pos,
- face) == net.minecraft.block.state.BlockFaceShape.UNDEFINED,
- "formation has no solid attachment face");
- }
-
- require(formation.getBlock().quantityDropped(world.rand) == 0, "formation has no survival drop");
-
- Set forms = EnumSet.noneOf(RetiredDecorations.Form.class);
-
- for (Block top : new Block[] { Blocks.AIR, Blocks.STONE, RetiredDecorations.STONE_FORMATIONS }) {
- for (Block bottom : new Block[] { Blocks.AIR, Blocks.STONE, RetiredDecorations.STONE_FORMATIONS }) {
- world.setBlockState(pos, formation, 2);
- world.setBlockState(pos.up(), top.getDefaultState(), 2);
- world.setBlockState(pos.down(), bottom.getDefaultState(), 2);
- forms.add(formation.getActualState(world, pos).getValue(RetiredDecorations.Formation.FORM));
- }
- }
-
- require(forms.size() == 8, "all calculated formation profiles");
- world.setBlockToAir(pos.up());
- world.setBlockToAir(pos.down());
- formation.getBlock().neighborChanged(formation, world, pos, Blocks.AIR, pos.down());
- require(world.getBlockState(pos).equals(formation), "migrated formation survives missing anchors");
-
- IBlockState poison = RetiredDecorations.JELLED_POISON.getDefaultState();
-
- world.setBlockState(pos, poison, 2);
- require(poison.isFullCube() && !poison.isOpaqueCube(), "poison full collision but translucent rendering");
- require(poison.getCollisionBoundingBox(world, pos).equals(Block.FULL_BLOCK_AABB), "poison collision");
- require(poison.getBlock().slipperiness == .9f
- && poison.getBlock().getBlockHardness(poison, world, pos) == .4f, "poison friction and hardness");
-
- net.minecraft.util.NonNullList drops = net.minecraft.util.NonNullList.create();
-
- poison.getBlock().getDrops(drops, world, pos, poison, 0);
- require(drops.size() == 1
- && drops.get(0).getItem() == net.minecraft.item.Item.getItemFromBlock(poison.getBlock()),
- "poison self drop");
-
- int registryCount = 0;
-
- for (Block block : Block.REGISTRY) {
- if (block.getRegistryName().getNamespace().equals(SkysBuildingPiecesBop.ID)) {
- registryCount++;
- }
- }
-
- require(registryCount == 74 && registryCount <= 80, "complete add-on registry budget");
- System.out.println(
- "BOP_RETIRED_DECORATIONS_PASS states=18 profiles=8 mixed_ids=true NBT=preserved reload=idempotent block_ids="
- + registryCount);
- } finally {
- world.setBlockState(pos, before, 2);
- world.setBlockState(pos.up(), above, 2);
- world.setBlockState(pos.down(), below, 2);
- LegacyBridge.report(world);
- counters.readFromNBT(previous);
- counters.markDirty();
- }
- }
-
- private static NBTTagCompound chunk(Block holder, int meta, IBlockState current) {
- NBTTagCompound root = new NBTTagCompound();
- NBTTagCompound level = new NBTTagCompound();
- NBTTagCompound section = new NBTTagCompound();
-
- root.setTag("Level", level);
-
- byte[] blockIdLowBytes = new byte[4096];
- byte[] high = new byte[2048];
- byte[] data = new byte[2048];
-
- int[] ids = { Block.getIdFromBlock(holder), Block.getIdFromBlock(current.getBlock()) };
-
- for (int entryIndex = 0; entryIndex < 2; entryIndex++) {
- blockIdLowBytes[entryIndex] = (byte) ids[entryIndex];
- LegacyBridge.setNibble(high, entryIndex, ids[entryIndex] >> 8);
- LegacyBridge.setNibble(data, entryIndex,
- entryIndex == 0 ? meta : current.getBlock().getMetaFromState(current));
- }
-
- section.setByte("Y", (byte) 4);
- section.setByteArray("Blocks", blockIdLowBytes);
- section.setByteArray("Add", high);
- section.setByteArray("Data", data);
-
- NBTTagList sections = new NBTTagList();
-
- sections.appendTag(section);
- level.setTag("Sections", sections);
- return root;
- }
-
- private static IBlockState read(NBTTagCompound section, int index) {
- int numeric = (section.getByteArray("Blocks")[index] & 255)
- | (LegacyBridge.nibble(section.getByteArray("Add"), index) << 8);
-
- return Block.getBlockById(numeric).getStateFromMeta(LegacyBridge.nibble(section.getByteArray("Data"), index));
- }
-
- private static void require(boolean value, String message) {
- if (!value) {
- throw new AssertionError(message);
- }
- }
-}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemChecks.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemChecks.java
index c06d659d..13f783a1 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemChecks.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemChecks.java
@@ -1,38 +1,48 @@
package zone.moddev.mc.skysbuildingpiecesbop.probe;
-import net.minecraft.init.Items;
-import net.minecraft.item.*;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.level.block.entity.ChestBlockEntity;
+
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.*;
import net.minecraft.nbt.*;
-import net.minecraft.util.ResourceLocation;
-import net.minecraft.util.datafix.FixTypes;
-import net.minecraft.world.WorldServer;
-import net.minecraftforge.fml.common.FMLCommonHandler;
-import net.minecraftforge.fml.common.registry.ForgeRegistries;
+import net.minecraft.resources.Identifier;
+import zone.moddev.mc.skysbuildingpieces.legacy.LegacyBridge;
+import net.minecraft.server.level.ServerLevel;
+
+import net.minecraftforge.registries.ForgeRegistries;
import zone.moddev.mc.skysbuildingpiecesbop.*;
import zone.moddev.mc.skysbuildingpiecesbop.basket.*;
import zone.moddev.mc.skysbuildingpieces.legacy.PiecesWorldState;
/** Target-native item resolution, multiplied counts and fail-closed capacity checks. */
public final class RetiredItemChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
private static int checks;
- public static void run(WorldServer world) {
+ public static void run(ServerLevel world) {
checks = 0;
LegacyItemRecovery recovery = new LegacyItemRecovery();
- NBTTagCompound state = PiecesWorldState.get(world).writeToNBT(new NBTTagCompound());
+ CompoundTag state = PiecesWorldState.get(world).saveTag(new CompoundTag());
String[] legacyIds = { "turnip_seeds", "pinecone", "dull_flower_band" };
int[] counts = { 1, 2, 8 };
for (int entryIndex = 0; entryIndex < 3; entryIndex++) {
- require(LegacyItemRecovery.HOLDERS[entryIndex].getCreativeTab() == null, "holder is hidden");
+ if (net.minecraftforge.fml.loading.FMLEnvironment.dist == net.minecraftforge.api.distmarker.Dist.CLIENT) {
+ require(BasketChecks.hidden(LegacyItemRecovery.HOLDERS[entryIndex], world), "holder is hidden");
+ }
- Item target = ForgeRegistries.ITEMS.getValue(new ResourceLocation(LegacyItemRecovery.target(entryIndex)));
+ Item target = ForgeRegistries.ITEMS.getValue(Identifier.parse(LegacyItemRecovery.target(entryIndex)));
for (String registryId : new String[] { "biomesoplenty:" + legacyIds[entryIndex],
- LegacyItemRecovery.HOLDERS[entryIndex].getRegistryName().toString() }) {
- NBTTagCompound raw = stack(registryId, 1, 7);
- NBTTagCompound tag = raw.getCompoundTag("tag").copy();
+ ForgeRegistries.ITEMS.getKey(LegacyItemRecovery.HOLDERS[entryIndex]).toString() }) {
+ CompoundTag raw = stack(registryId, 1, 7);
+ CompoundTag tag = raw.getCompoundOrEmpty("tag").copy();
if (target == null || target == Items.AIR) {
reject(recovery, raw, "unavailable");
@@ -40,188 +50,204 @@ public static void run(WorldServer world) {
}
recovery.fixTagCompound(raw);
- require(raw.getString("id").equals(LegacyItemRecovery.target(entryIndex)),
+ require(raw.getStringOr("id", "").equals(LegacyItemRecovery.target(entryIndex)),
"target identity " + registryId);
- require(raw.getInteger("Count") == counts[entryIndex] && raw.getInteger("Damage") == 0,
+ require(raw.getIntOr("Count", 0) == counts[entryIndex] && raw.getIntOr("Damage", 0) == 0,
"target quantity and variant");
- require(raw.getCompoundTag("tag").equals(tag), "unrelated custom NBT");
+ require(raw.getCompoundOrEmpty("tag").equals(tag), "unrelated custom NBT");
- ItemStack decoded = new ItemStack(raw);
+ ItemStack decoded = BopProbeItems.read(raw);
require(decoded.getItem() == target && decoded.getCount() == counts[entryIndex],
"real item deserialization");
if (entryIndex == 2) {
- require(decoded.getTranslationKey().contains("clover"), "clover metadata, not another flower");
+ require(decoded.getItem().getDescriptionId().contains("oxeye_daisy"), "retired flower target");
}
- NBTTagCompound once = raw.copy();
+ CompoundTag once = raw.copy();
recovery.fixTagCompound(raw);
require(raw.equals(once), "reload idempotence");
}
}
- NBTTagCompound chest = chest();
- NBTTagList list = new NBTTagList();
- NBTTagCompound pine = stack("biomesoplenty:pinecone", 64, 4);
+ CompoundTag chest = chest();
+ ListTag list = new ListTag();
+ CompoundTag pine = stack("biomesoplenty:pinecone", 64, 4);
- list.appendTag(pine);
+ list.add(pine);
- NBTTagCompound stone = new ItemStack(Items.STICK, 64).writeToNBT(new NBTTagCompound());
+ CompoundTag stone = BopProbeItems.legacy(new ItemStack(Items.STICK, 64));
- stone.setByte("Slot", (byte) 0);
- list.appendTag(stone);
- chest.setTag("Items", list);
+ stone.putByte("Slot", (byte) 0);
+ list.add(stone);
+ chest.put("Items", list);
recovery.fixTagCompound(chest);
- require(list.tagCount() == 2, "input references were not mutated");
- list = chest.getTagList("Items", 10);
- require(list.tagCount() == 3, "overflow creates a second legal stack");
+ require(list.size() == 2, "input references were not mutated");
+ list = chest.getListOrEmpty("Items");
+ require(list.size() == 3, "overflow creates a second legal stack");
int dyes = 0;
- for (int entryIndex = 0; entryIndex < list.tagCount(); entryIndex++) {
- NBTTagCompound compoundTag = list.getCompoundTagAt(entryIndex);
+ for (int entryIndex = 0; entryIndex < list.size(); entryIndex++) {
+ CompoundTag compoundTag = list.getCompoundOrEmpty(entryIndex);
- if (compoundTag.getString("id").equals("biomesoplenty:brown_dye")) {
- dyes += compoundTag.getInteger("Count");
- require(compoundTag.getInteger("Count") == 64, "legal stack limit");
- require(compoundTag.getCompoundTag("tag").equals(pine.getCompoundTag("tag")),
+ if (compoundTag.getStringOr("id", "").equals("minecraft:brown_dye")) {
+ dyes += compoundTag.getIntOr("Count", 0);
+ require(compoundTag.getIntOr("Count", 0) == 64, "legal stack limit");
+ require(compoundTag.getCompoundOrEmpty("tag").equals(pine.getCompoundOrEmpty("tag")),
"split copies complete custom data");
}
}
- require(dyes == 128 && list.getCompoundTagAt(2).getInteger("Slot") == 1, "exact multiplier and free slot");
+ require(dyes == 128 && list.getCompoundOrEmpty(2).getIntOr("Slot", 0) == 1, "exact multiplier and free slot");
- NBTTagCompound full = chest();
+ CompoundTag full = chest();
- list = new NBTTagList();
+ list = new ListTag();
for (int entryIndex = 0; entryIndex < 27; entryIndex++) {
- NBTTagCompound entry = entryIndex == 0 ? stack("biomesoplenty:pinecone", 64, entryIndex)
- : new ItemStack(Items.STICK, 64).writeToNBT(new NBTTagCompound());
+ CompoundTag entry = entryIndex == 0 ? stack("biomesoplenty:pinecone", 64, entryIndex)
+ : BopProbeItems.legacy(new ItemStack(Items.STICK, 64));
- entry.setByte("Slot", (byte) entryIndex);
- list.appendTag(entry);
+ entry.putByte("Slot", (byte) entryIndex);
+ list.add(entry);
}
- full.setTag("Items", list);
+ full.put("Items", list);
reject(recovery, full, "empty inventory slots");
reject(recovery, stack("biomesoplenty:pinecone", 64, 0), "extra slots");
- NBTTagCompound frame = new NBTTagCompound();
+ CompoundTag frame = new CompoundTag();
- frame.setString("id", "minecraft:item_frame");
- frame.setTag("Item", stack("biomesoplenty:pinecone", 1, 0));
+ frame.putString("id", "minecraft:item_frame");
+ frame.put("Item", stack("biomesoplenty:pinecone", 1, 0));
reject(recovery, frame, "item frame");
- NBTTagCompound bad = stack("biomesoplenty:pinecone", 1, 0);
+ CompoundTag bad = stack("biomesoplenty:pinecone", 1, 0);
- bad.setString("tag", "invalid");
+ bad.putString("tag", "invalid");
reject(recovery, bad, "malformed");
bad = stack("biomesoplenty:pinecone", 1, 0);
- bad.setByte("Count", (byte) 0);
+ bad.putByte("Count", (byte) 0);
reject(recovery, bad, "invalid saved stack");
- NBTTagCompound basket = new ItemStack(Baskets.ITEM).writeToNBT(new NBTTagCompound());
- NBTTagCompound tag = new NBTTagCompound();
+ CompoundTag basket = BopProbeItems.legacy(new ItemStack(Baskets.ITEM));
+ CompoundTag tag = new CompoundTag();
- list = new NBTTagList();
- list.appendTag(stack("biomesoplenty:pinecone", 64, 2));
- tag.setTag("Items", list);
- basket.setTag("tag", tag);
+ list = new ListTag();
+ list.add(stack("biomesoplenty:pinecone", 64, 2));
+ tag.put("Items", list);
+ basket.put("tag", tag);
recovery.fixTagCompound(basket);
- require(basket.getCompoundTag("tag").getTagList("Items", 10).tagCount() == 2,
+ require(basket.getCompoundOrEmpty("tag").getListOrEmpty("Items").size() == 2,
"basket overflow is allocated before validation");
- require(new BasketInventory(new ItemStack(basket)).getStackInSlot(0).getCount() == 64,
+ require(new BasketInventory(BopProbeItems.read(basket), BopProbeItems.registries()).getItem(0).getCount() == 64,
"legacy non-plant output stays withdrawable");
- NBTTagCompound player = new NBTTagCompound();
+ CompoundTag player = new CompoundTag();
- player.setInteger("DataVersion", 1343);
- player.setTag("abilities", new NBTTagCompound());
+ player.putInt("DataVersion", 1343);
+ player.put("abilities", new CompoundTag());
- NBTTagCompound versions = new NBTTagCompound();
+ CompoundTag versions = new CompoundTag();
- versions.setInteger(SkysBuildingPiecesBop.ID, 1);
- player.setTag("ForgeDataVersion", versions);
- list = new NBTTagList();
- list.appendTag(stack("biomesoplenty:pinecone", 64, 0));
- player.setTag("Inventory", list);
- list = new NBTTagList();
- list.appendTag(stack("biomesoplenty:pinecone", 40, 3));
- player.setTag("EnderItems", list);
- FMLCommonHandler.instance().getDataFixer().process(FixTypes.PLAYER, player);
- require(player.getTagList("Inventory", 10).tagCount() == 2
- && player.getTagList("EnderItems", 10).tagCount() == 2,
+ versions.putInt(SkysBuildingPiecesBop.ID, 1);
+ player.put("ForgeDataVersion", versions);
+ list = new ListTag();
+ list.add(stack("biomesoplenty:pinecone", 64, 0));
+ player.put("Inventory", list);
+ list = new ListTag();
+ list.add(stack("biomesoplenty:pinecone", 40, 3));
+ player.put("EnderItems", list);
+ LegacyBridge.prepareNbt(player);
+ require(player.getListOrEmpty("Inventory").size() == 2 && player.getListOrEmpty("EnderItems").size() == 2,
"version-one saves receive version-two player and ender conversion");
- NBTTagCompound deferred = stack("biomesoplenty:pinecone", 64, 4);
+ CompoundTag deferred = stack("biomesoplenty:pinecone", 64, 4);
- deferred.setInteger("DataVersion", 1343);
- deferred.setTag("ForgeDataVersion", versions.copy());
+ deferred.putInt("DataVersion", 1343);
+ deferred.put("ForgeDataVersion", versions.copy());
- NBTTagCompound same = deferred.copy();
+ CompoundTag same = deferred.copy();
- FMLCommonHandler.instance().getDataFixer().process(FixTypes.ITEM_INSTANCE, deferred);
- require(deferred.equals(same), "child-first fixing defers larger output without truncation");
+ boolean blocked = false;
- NBTTagCompound parent = chest();
+ try {
+ LegacyBridge.prepareNbt(deferred);
+ } catch (LegacyBridge.RecoveryAbort expected) {
+ blocked = true;
+ }
+
+ require(blocked && deferred.equals(same), "a child without parent capacity stops without truncation");
- parent.setInteger("DataVersion", 1343);
- parent.setTag("ForgeDataVersion", versions.copy());
- list = new NBTTagList();
- list.appendTag(deferred);
- parent.setTag("Items", list);
- FMLCommonHandler.instance().getDataFixer().process(FixTypes.BLOCK_ENTITY, parent);
+ CompoundTag parent = chest();
- net.minecraft.tileentity.TileEntityChest decodedChest = new net.minecraft.tileentity.TileEntityChest();
+ parent.putInt("DataVersion", 1343);
+ parent.put("ForgeDataVersion", versions.copy());
+ list = new ListTag();
+ list.add(deferred);
+ parent.put("Items", list);
+ LegacyBridge.prepareNbt(parent);
- decodedChest.readFromNBT(parent);
- require(decodedChest.getStackInSlot(4).getCount() == 64 && decodedChest.getStackInSlot(0).getCount() == 64,
+ net.minecraft.world.level.block.entity.ChestBlockEntity decodedChest = new net.minecraft.world.level.block.entity.ChestBlockEntity(
+ net.minecraft.core.BlockPos.ZERO, net.minecraft.world.level.block.Blocks.CHEST.defaultBlockState());
+
+ ListTag upgradedItems = new ListTag();
+ for (net.minecraft.nbt.Tag value : parent.getListOrEmpty("Items")) {
+ CompoundTag old = (CompoundTag) value;
+ CompoundTag item = BopProbeItems.save(BopProbeItems.read(old));
+ item.putByte("Slot", old.getByteOr("Slot", (byte) 0));
+ upgradedItems.add(item);
+ }
+ parent.put("Items", upgradedItems);
+ decodedChest.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create(
+ net.minecraft.util.ProblemReporter.DISCARDING, world.registryAccess(), parent));
+ require(decodedChest.getItem(4).getCount() == 64 && decodedChest.getItem(0).getCount() == 64,
"parent-first and child-first orders both deserialize 128 items");
- NBTTagCompound caps = stack("biomesoplenty:pinecone", 1, 0);
- NBTTagCompound payload = new NBTTagCompound();
+ CompoundTag caps = stack("biomesoplenty:pinecone", 1, 0);
+ CompoundTag payload = new CompoundTag();
- payload.setString("custom", "retain capabilities");
- caps.setTag("ForgeCaps", payload);
+ payload.putString("custom", "retain capabilities");
+ caps.put("ForgeCaps", payload);
recovery.fixTagCompound(caps);
- require(caps.getCompoundTag("tag").getCompoundTag(SkysBuildingPiecesBop.ID + ":legacy_capabilities")
- .equals(payload), "old capabilities retain an exact archive");
- require(PiecesWorldState.get(world).writeToNBT(new NBTTagCompound()).equals(state),
+ require(caps.getCompoundOrEmpty("tag").getCompoundOrEmpty(SkysBuildingPiecesBop.ID + ":legacy_capabilities").equals(payload),
+ "old capabilities retain an exact archive");
+ require(PiecesWorldState.get(world).saveTag(new CompoundTag()).equals(state),
"piece counters and schema untouched");
- System.out.println("BOP_RETIRED_ITEMS_PASS checks=" + checks
+ LOGGER.info("BOP_RETIRED_ITEMS_PASS checks=" + checks
+ " multipliers=1/2/8 capacity=bounded NBT=preserved schema=unchanged");
}
- private static NBTTagCompound chest() {
- NBTTagCompound compoundTag = new NBTTagCompound();
+ private static CompoundTag chest() {
+ CompoundTag compoundTag = new CompoundTag();
- compoundTag.setString("id", "minecraft:chest");
+ compoundTag.putString("id", "minecraft:chest");
return compoundTag;
}
- private static NBTTagCompound stack(String registryId, int count, int slot) {
- NBTTagCompound compoundTag = new NBTTagCompound();
+ private static CompoundTag stack(String registryId, int count, int slot) {
+ CompoundTag compoundTag = new CompoundTag();
- compoundTag.setString("id", registryId);
- compoundTag.setByte("Count", (byte) count);
- compoundTag.setShort("Damage", (short) 0);
- compoundTag.setByte("Slot", (byte) slot);
+ compoundTag.putString("id", registryId);
+ compoundTag.putByte("Count", (byte) count);
+ compoundTag.putShort("Damage", (short) 0);
+ compoundTag.putByte("Slot", (byte) slot);
- NBTTagCompound tag = new NBTTagCompound();
- NBTTagCompound display = new NBTTagCompound();
+ CompoundTag tag = new CompoundTag();
+ CompoundTag display = new CompoundTag();
- display.setString("Name", "Archived plant");
- tag.setTag("display", display);
- tag.setInteger("Unrelated", 711);
- compoundTag.setTag("tag", tag);
+ display.putString("Name", "\"Archived plant\"");
+ tag.put("display", display);
+ tag.putInt("Unrelated", 711);
+ compoundTag.put("tag", tag);
return compoundTag;
}
- private static void reject(LegacyItemRecovery fix, NBTTagCompound raw, String expected) {
- NBTTagCompound before = raw.copy();
+ private static void reject(LegacyItemRecovery fix, CompoundTag raw, String expected) {
+ CompoundTag before = raw.copy();
boolean stopped = false;
try {
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemUpgrade.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemUpgrade.java
deleted file mode 100644
index 593bb81d..00000000
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemUpgrade.java
+++ /dev/null
@@ -1,88 +0,0 @@
-package zone.moddev.mc.skysbuildingpiecesbop.probe;
-
-import java.io.*;
-import net.minecraft.item.ItemStack;
-import net.minecraft.nbt.*;
-import net.minecraft.tileentity.TileEntityChest;
-import net.minecraft.util.math.BlockPos;
-import net.minecraft.world.WorldServer;
-import zone.moddev.mc.skysbuildingpiecesbop.LegacyItemRecovery;
-
-/** Compare genuine original stacks after the normal chunk loader, including a delayed second chunk. */
-public final class RetiredItemUpgrade {
- public static void run(WorldServer world, String phase) throws Exception {
- NBTTagCompound expected;
-
- try (InputStream input = new FileInputStream(
- new File(world.getSaveHandler().getWorldDirectory().getParentFile(), "expected-retired-items.dat"))) {
- expected = CompressedStreamTools.readCompressed(input);
- }
-
- NBTTagList source = expected.getTagList("Items", 10);
- int split = (source.tagCount() + 1) / 2;
- boolean reload = phase.endsWith("reload");
- int units = 0;
-
- for (int group = 0; group < (reload ? 2 : 1); group++) {
- int blockX = 299 + 1024 * group;
- int blockZ = -22 + 1024 * group;
-
- world.getChunk(blockX >> 4, blockZ >> 4);
-
- TileEntityChest chest = (TileEntityChest) world.getTileEntity(new BlockPos(blockX, 69, blockZ));
-
- if (chest == null) {
- throw new AssertionError("Retired-item source chest lost");
- }
-
- int start = group == 0 ? 0 : split;
- int end = group == 0 ? split : source.tagCount();
-
- for (int entryIndex = start; entryIndex < end; entryIndex++) {
- NBTTagCompound raw = source.getCompoundTagAt(entryIndex).copy();
-
- new LegacyItemRecovery().fixTagCompound(raw);
-
- ItemStack target = new ItemStack(raw);
- ItemStack actual = chest.getStackInSlot(entryIndex - start);
-
- if (!ItemStack.areItemStacksEqual(target, actual)) {
- throw new AssertionError(
- "Retired-item fixture changed identity, quantity or NBT: " + actual + " != " + target);
- }
-
- units += actual.getCount();
- }
- if (group == 0) {
- int expanded = 0;
- int current = 0;
-
- for (int slot = 0; slot < chest.getSizeInventory(); slot++) {
- ItemStack stack = chest.getStackInSlot(slot);
-
- if (stack.hasTagCompound() && stack.getTagCompound().hasKey("Fixture", 8)) {
- if (stack.getCount() > 64 || stack.getTagCompound().getInteger("Unrelated") != 719) {
- throw new AssertionError("Expanded item payload changed");
- }
- if (stack.getTagCompound().getString("Fixture").equals("stack-expansion")) {
- expanded += stack.getCount();
- }
- if (stack.getTagCompound().getString("Fixture").equals("current-dye")) {
- current += stack.getCount();
- }
- }
- }
- if (expanded != 128 || current != 9) {
- throw new AssertionError(
- "Stack expansion or mixed current dye changed: " + expanded + " / " + current);
- }
- }
- }
- if (reload && units != 77) {
- throw new AssertionError("Expected exactly 1 seed + 68 dyes + 8 clover, got " + units);
- }
-
- System.out.println(
- "BOP_RETIRED_ITEM_UPGRADE_PASS phase=" + phase + " units=" + units + " delayed_chunk=" + reload);
- }
-}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemWorldChecks.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemWorldChecks.java
index 9f8a38ac..ddba80c4 100644
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemWorldChecks.java
+++ b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredItemWorldChecks.java
@@ -1,31 +1,47 @@
package zone.moddev.mc.skysbuildingpiecesbop.probe;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.storage.LevelResource;
+
import java.io.*;
import java.nio.file.*;
import net.minecraft.nbt.*;
-import net.minecraft.world.WorldServer;
-import net.minecraftforge.fml.common.FMLCommonHandler;
-import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
-import net.minecraftforge.fml.common.gameevent.TickEvent;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraftforge.server.ServerLifecycleHooks;
+import net.minecraftforge.event.TickEvent;
import zone.moddev.mc.skysbuildingpieces.legacy.*;
/** A fresh full-world copy, normally loading every carrier of the three retired item types. */
public final class RetiredItemWorldChecks {
+ private static final org.apache.logging.log4j.Logger LOGGER =
+ org.apache.logging.log4j.LogManager.getLogger();
+
private int ticks;
- @SubscribeEvent
- public void tick(TickEvent.ServerTickEvent event) throws Exception {
- if (event.phase != TickEvent.Phase.END) {
- return;
- }
+ private net.minecraftforge.eventbus.api.listener.EventListener listener;
+
+ static void register() {
+ var check = new RetiredItemWorldChecks();
+ check.listener = TickEvent.ServerTickEvent.Post.BUS.addListener(event -> {
+ try {
+ check.tick(event);
+ } catch (Exception failure) {
+ throw new IllegalStateException("Cannot audit the copied world", failure);
+ }
+ });
+ }
+
+ public void tick(TickEvent.ServerTickEvent.Post event) throws Exception {
- net.minecraft.server.MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance();
+ net.minecraft.server.MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
- if (server == null || server.getWorld(0) == null) {
+ if (server == null || server.getLevel(net.minecraft.world.level.Level.OVERWORLD) == null) {
return;
}
- WorldServer world = server.getWorld(0);
+ ServerLevel world = server.getLevel(net.minecraft.world.level.Level.OVERWORLD);
if (ticks++ == 0) {
RetiredItemChecks.run(world);
@@ -43,28 +59,28 @@ public void tick(TickEvent.ServerTickEvent event) throws Exception {
LegacyBridge.report(world);
- NBTTagCompound state = PiecesWorldState.get(world).writeToNBT(new NBTTagCompound());
- File snapshot = new File(world.getSaveHandler().getWorldDirectory(), "retired-item-qualification-state.dat");
+ CompoundTag state = PiecesWorldState.get(world).saveTag(new CompoundTag());
+ File snapshot = new File(world.getServer().getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toFile(), "retired-item-qualification-state.dat");
boolean reload = System.getProperty("skysbuildingpieces.integrationPhase").endsWith("reload");
if (reload) {
- NBTTagCompound before;
+ CompoundTag before;
try (InputStream input = new FileInputStream(snapshot)) {
- before = CompressedStreamTools.readCompressed(input);
+ before = NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap());
}
if (!state.equals(before)) {
throw new AssertionError("Retired-item reload changed piece migration state");
}
} else {
try (OutputStream output = new FileOutputStream(snapshot)) {
- CompressedStreamTools.writeCompressed(state, output);
+ NbtIo.writeCompressed(state, output);
}
}
- System.out.println("BOP_RETIRED_ITEM_SYLVESTER_PASS all_carriers_loaded=true schema="
- + state.getInteger("schema_version") + " reload=" + reload);
- net.minecraftforge.common.MinecraftForge.EVENT_BUS.unregister(this);
- server.initiateShutdown();
+ LOGGER.info("BOP_RETIRED_ITEM_SYLVESTER_PASS all_carriers_loaded=true schema="
+ + state.getIntOr("schema_version", 0) + " reload=" + reload);
+ TickEvent.ServerTickEvent.Post.BUS.removeListener(listener);
+ server.halt(false);
}
}
diff --git a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredRockUpgrade.java b/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredRockUpgrade.java
deleted file mode 100644
index bd357c9f..00000000
--- a/src/integrationTest/java/zone/moddev/mc/skysbuildingpiecesbop/probe/RetiredRockUpgrade.java
+++ /dev/null
@@ -1,120 +0,0 @@
-package zone.moddev.mc.skysbuildingpiecesbop.probe;
-
-import java.io.*;
-import java.util.*;
-import net.minecraft.block.state.IBlockState;
-import net.minecraft.entity.item.EntityItem;
-import net.minecraft.item.ItemStack;
-import net.minecraft.nbt.NBTTagCompound;
-import net.minecraft.tileentity.TileEntityChest;
-import net.minecraft.util.math.BlockPos;
-import net.minecraft.world.WorldServer;
-import zone.moddev.mc.skysbuildingpieces.content.Pieces;
-import zone.moddev.mc.skysbuildingpieces.legacy.*;
-
-/** Checks a world saved by the exact accepted 1.10 production jars. */
-public final class RetiredRockUpgrade {
- public static void run(WorldServer world, String phase) throws Exception {
- if (phase.startsWith("retired-rocks-items-")) {
- RetiredItemUpgrade.run(world, phase);
- return;
- }
- if (phase.startsWith("retired-rocks-baskets-")) {
- BasketUpgrade.run(world, phase);
- return;
- }
-
- String[] materials = { "limestone", "siltstone", "shale", "limestone_polished", "siltstone_polished",
- "shale_polished", "crag_rock" };
-
- world.getChunk(31, 31);
-
- TileEntityChest chest = (TileEntityChest) world.getTileEntity(new BlockPos(508, 100, 508));
-
- if (chest == null) {
- throw new AssertionError("Source chest lost");
- }
- for (int entryIndex = 0; entryIndex < materials.length; entryIndex++) {
- IBlockState expected = Pieces
- .nativeState(zone.moddev.mc.skysbuildingpieces.catalogue.Catalogue.INSTANCE.materials
- .get("biomesoplenty:" + materials[entryIndex]), "full");
-
- if (!world.getBlockState(new BlockPos(500 + entryIndex, 100, 500)).equals(expected)) {
- throw new AssertionError("Retired full block identity " + entryIndex);
- }
-
- ItemStack actual = chest.getStackInSlot(entryIndex);
- ItemStack target = Pieces.stack(expected, 17 + entryIndex);
-
- if (actual.isEmpty() || actual.getItem() != target.getItem() || actual.getMetadata() != target.getMetadata()
- || actual.getCount() != 17 + entryIndex) {
- throw new AssertionError("Retired chest identity/count " + entryIndex);
- }
-
- NBTTagCompound tag = actual.getTagCompound();
-
- if (tag == null || !tag.getString("Fixture").equals("retired-rock-" + entryIndex)
- || !tag.getCompoundTag("display").getString("Name").equals("Preserved rock " + entryIndex)
- || tag.getCompoundTag("Unrelated").getInteger("Number") != 700 + entryIndex) {
- throw new AssertionError("Retired custom item NBT " + entryIndex);
- }
- }
-
- int entities = 0;
- int units = 0;
-
- for (net.minecraft.entity.Entity entity : world.loadedEntityList) {
- if (entity instanceof EntityItem) {
- ItemStack stack = ((EntityItem) entity).getItem();
-
- if (stack.getTagCompound() != null
- && stack.getTagCompound().getString("Fixture").startsWith("retired-rock-")) {
- int entryIndex = Integer.parseInt(stack.getTagCompound().getString("Fixture").substring(13));
- IBlockState expected = Pieces
- .nativeState(zone.moddev.mc.skysbuildingpieces.catalogue.Catalogue.INSTANCE.materials
- .get("biomesoplenty:" + materials[entryIndex]), "full");
- ItemStack target = Pieces.stack(expected, 17 + entryIndex);
-
- if (stack.getItem() != target.getItem() || stack.getMetadata() != target.getMetadata()
- || stack.getCount() != 17 + entryIndex) {
- throw new AssertionError("Retired entity stack " + entryIndex);
- }
-
- entities++;
- units += stack.getCount();
- }
- }
- }
- if (entities != 7 || units != 140) {
- throw new AssertionError("Retired entities: " + entities + " / " + units);
- }
-
- LegacyBridge.report(world);
-
- PiecesWorldState state = PiecesWorldState.get(world);
- PiecesWorldState.Totals retired = state.modules.get(HistoricalFullBlocks.MODULE);
-
- if (retired == null || retired.blocks != 7 || retired.items != 280) {
- throw new AssertionError("Retired fixture counters: "
- + (retired == null ? "missing" : retired.blocks + " / " + retired.items));
- }
-
- File marker = new File(world.getSaveHandler().getWorldDirectory(), "retired-rock-upgrade.properties");
- Properties saved = new Properties();
-
- if (marker.isFile()) {
- try (InputStream input = new FileInputStream(marker)) {
- saved.load(input);
- }
- }
-
- saved.setProperty(phase, "passed");
-
- try (OutputStream output = new FileOutputStream(marker)) {
- saved.store(output, "Disposable retired-rock qualification");
- }
-
- System.out.println(
- "RETIRED_ROCK_UPGRADE_PASS " + phase + " variants=7 chest_units=140 entity_units=140 counters=7/280");
- }
-}
diff --git a/src/integrationTest/resources/META-INF/mods.toml b/src/integrationTest/resources/META-INF/mods.toml
new file mode 100644
index 00000000..d2bd1c3b
--- /dev/null
+++ b/src/integrationTest/resources/META-INF/mods.toml
@@ -0,0 +1,11 @@
+modLoader="javafml"
+loaderVersion="[31,)"
+license="LGPL-2.1-only"
+[[mods]]
+modId="buildingpiecesprobe"
+version="1.0"
+displayName="Building Pieces Runtime Checks"
+[[mods]]
+modId="basketsprobe"
+version="1.0"
+displayName="BOP Basket Runtime Checks"
diff --git a/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/BopFlattening.java b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/BopFlattening.java
new file mode 100644
index 00000000..4065eeb4
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/BopFlattening.java
@@ -0,0 +1,135 @@
+package zone.moddev.mc.skysbuildingpiecesbop;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.state.properties.Property;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Catalogue;
+import zone.moddev.mc.skysbuildingpieces.catalogue.Shape;
+import zone.moddev.mc.skysbuildingpieces.content.Pieces;
+import zone.moddev.mc.skysbuildingpieces.legacy.HistoricalFullBlocks;
+
+/** Recover the old material palettes before vanilla tries to flatten their numeric states. */
+public final class BopFlattening {
+ public static final String MODULE = "bop_flattening";
+
+ private BopFlattening() {
+ }
+
+ public static void register() {
+ for (String holder : new String[] { "stone", "crag_rock", "stone_formations", "jelled_poison", "tree_moss" }) {
+ HistoricalFullBlocks.registerHolderCoverage(SkysBuildingPiecesBop.ID + ":legacy_bop_" + holder, MODULE);
+ HistoricalFullBlocks.registerFlatteningCoverage("biomesoplenty:" + holder, MODULE);
+ }
+
+ HistoricalFullBlocks.registerStates(SkysBuildingPiecesBop.ID + ":stone_formations",
+ SkysBuildingPiecesBop.ID + ":stone_formations", MODULE,
+ metadata -> metadata == 0 ? RetiredDecorations.STONE_FORMATIONS.defaultBlockState() : null);
+ HistoricalFullBlocks.registerStates(SkysBuildingPiecesBop.ID + ":jelled_poison",
+ SkysBuildingPiecesBop.ID + ":jelled_poison", MODULE,
+ metadata -> metadata == 0 ? RetiredDecorations.JELLED_POISON.defaultBlockState() : null);
+
+ String path = "/assets/skysbuildingpiecesbop/catalogue/legacy_states.json";
+ Map palettes = new LinkedHashMap<>();
+
+ try (InputStreamReader reader = new InputStreamReader(BopFlattening.class.getResourceAsStream(path),
+ StandardCharsets.UTF_8)) {
+ JsonArray rows = new Gson().fromJson(reader, JsonArray.class);
+
+ for (JsonElement element : rows) {
+ JsonObject row = element.getAsJsonObject();
+ String source = row.get("id").getAsString();
+ int metadata = row.get("meta").getAsInt();
+ JsonObject[] states = palettes.computeIfAbsent(source, key -> new JsonObject[16]);
+
+ if (metadata < 0 || metadata >= states.length || states[metadata] != null) {
+ throw new IllegalStateException("Duplicate or invalid BOP legacy state: " + row);
+ }
+
+ states[metadata] = row;
+ }
+ } catch (java.io.IOException failure) {
+ throw new IllegalStateException("Cannot read the BOP legacy state table", failure);
+ }
+ for (JsonElement element : BopWorldMappings.rows()) {
+ JsonObject row = element.getAsJsonObject();
+ String source = row.get("id").getAsString();
+ int metadata = row.get("meta").getAsInt();
+ JsonObject[] states = palettes.computeIfAbsent(source, key -> new JsonObject[16]);
+
+ if (metadata < 0 || metadata >= states.length || states[metadata] != null) {
+ throw new IllegalStateException("Duplicate or invalid BOP world state: " + row);
+ }
+
+ states[metadata] = row;
+ }
+ for (Map.Entry palette : palettes.entrySet()) {
+ HistoricalFullBlocks.registerStates(palette.getKey(), palette.getKey(), MODULE,
+ metadata -> resolve(palette.getValue()[metadata]));
+ }
+
+ HistoricalFullBlocks.registerStates(SkysBuildingPiecesBop.ID + ":retired_rocks",
+ SkysBuildingPiecesBop.ID + ":retired_rocks", MODULE, metadata -> {
+ if (metadata < 0 || metadata >= RetiredRocks.NAMES.length) {
+ return null;
+ }
+
+ String[] materials = { "crag_rock", "limestone", "limestone_polished", "shale", "shale_polished",
+ "siltstone", "siltstone_polished" };
+
+ return Pieces.nativeState(Catalogue.INSTANCE.materials.get("biomesoplenty:" + materials[metadata]),
+ "full");
+ });
+ }
+
+ static BlockState resolve(JsonObject row) {
+ if (row == null) {
+ return null;
+ }
+ if (row.has("target")) {
+ return BopWorldMappings.state(row.getAsJsonObject("target"));
+ }
+
+ String material = row.get("material").getAsString();
+ String role = row.get("role").getAsString();
+ BlockState result;
+
+ if (role.equals("slab")) {
+ result = Pieces.state(material, Shape.SLAB,
+ row.getAsJsonObject("properties").get("half").getAsString().equals("top") ? 0 : 1);
+ } else if (role.equals("stairs")) {
+ result = Pieces.state(material, Shape.STAIRS, row.get("meta").getAsInt());
+ } else {
+ result = Pieces.nativeState(Catalogue.INSTANCE.materials.get(material), role);
+ }
+ if (result == null) {
+ throw new IllegalStateException("No equivalent BOP geometry: " + row);
+ }
+ for (Map.Entry property : row.getAsJsonObject("properties").entrySet()) {
+ // Variant selects the material; the remaining native properties retain orientation and gate state.
+ if (property.getKey().equals("variant") || role.equals("slab") && property.getKey().equals("half")) {
+ continue;
+ }
+
+ Property> target = result.getBlock().getStateDefinition().getProperty(property.getKey());
+
+ if (target != null) {
+ result = withProperty(result, target, property.getValue().getAsString());
+ }
+ }
+
+ return result;
+ }
+
+ private static > BlockState withProperty(BlockState state, Property property,
+ String value) {
+ return state.setValue(property, property.getValue(value).orElseThrow(
+ () -> new IllegalStateException("Invalid recovered property " + property.getName() + "=" + value)));
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/BopWorldMappings.java b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/BopWorldMappings.java
new file mode 100644
index 00000000..32c0c296
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/BopWorldMappings.java
@@ -0,0 +1,84 @@
+package zone.moddev.mc.skysbuildingpiecesbop;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.state.properties.Property;
+import net.minecraft.resources.Identifier;
+import net.minecraftforge.registries.ForgeRegistries;
+
+/** Named equivalents for native BOP blocks saved before flattening. */
+public final class BopWorldMappings {
+ private BopWorldMappings() {
+ }
+
+ public static void registerBiomes() {
+ Map aliases = new LinkedHashMap<>();
+
+ try (InputStreamReader reader = new InputStreamReader(BopWorldMappings.class.getResourceAsStream(
+ "/assets/skysbuildingpiecesbop/catalogue/legacy_biomes.json"), StandardCharsets.UTF_8)) {
+ JsonObject values = new Gson().fromJson(reader, JsonObject.class);
+
+ values.entrySet().forEach(entry -> aliases.put(entry.getKey(), entry.getValue().getAsString()));
+ } catch (java.io.IOException failure) {
+ throw new IllegalStateException("Cannot read retired BOP biome mappings", failure);
+ }
+
+ zone.moddev.mc.skysbuildingpieces.legacy.HistoricalBiomes.register("biomesoplenty", aliases);
+ }
+
+ public static JsonArray rows() {
+ try (InputStreamReader reader = new InputStreamReader(BopWorldMappings.class.getResourceAsStream(
+ "/assets/skysbuildingpiecesbop/catalogue/legacy_world_states.json"), StandardCharsets.UTF_8)) {
+ return new Gson().fromJson(reader, JsonArray.class);
+ } catch (java.io.IOException failure) {
+ throw new IllegalStateException("Cannot read native BOP world mappings", failure);
+ }
+ }
+
+ public static BlockState state(JsonObject target) {
+ if (target.has("unsupported")) {
+ throw new IllegalStateException(target.get("unsupported").getAsString());
+ }
+
+ String savedTarget = target.get("id").getAsString();
+ Identifier identity = Identifier.parse(savedTarget.equals("minecraft:grass")
+ ? "minecraft:short_grass" : NamedBopRecovery.currentMapleBlock(savedTarget));
+ Block block = ForgeRegistries.BLOCKS.getValue(identity);
+
+ if (block == null || block == Blocks.AIR) {
+ throw new IllegalStateException("Missing BOP world replacement: " + identity);
+ }
+
+ BlockState state = block.defaultBlockState();
+
+ for (Map.Entry entry : target.getAsJsonObject("properties").entrySet()) {
+ Property> property = block.getStateDefinition().getProperty(entry.getKey());
+
+ if (property == null) {
+ throw new IllegalStateException("Missing recovered property " + entry.getKey() + " on " + identity);
+ }
+
+ state = withProperty(state, property, entry.getValue().getAsString());
+ }
+ if (state.hasBlockEntity()) {
+ throw new IllegalStateException("BOP replacements must not introduce a block entity: " + identity);
+ }
+
+ return state;
+ }
+
+ private static > BlockState withProperty(BlockState state, Property property,
+ String value) {
+ return state.setValue(property, property.getValue(value).orElseThrow(
+ () -> new IllegalStateException("Invalid recovered property " + property.getName() + "=" + value)));
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/LegacyItemRecovery.java b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/LegacyItemRecovery.java
index 2ec6c298..6bf8e644 100644
--- a/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/LegacyItemRecovery.java
+++ b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/LegacyItemRecovery.java
@@ -1,81 +1,68 @@
package zone.moddev.mc.skysbuildingpiecesbop;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.entity.decoration.ItemFrame;
+import net.minecraft.world.entity.player.Inventory;
+
import java.util.*;
-import net.minecraft.item.*;
+import net.minecraft.world.item.*;
import net.minecraft.nbt.*;
-import net.minecraft.util.ResourceLocation;
-import net.minecraft.util.datafix.IFixableData;
-import net.minecraftforge.event.RegistryEvent;
-import net.minecraftforge.fml.common.Mod;
-import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
-import net.minecraftforge.fml.common.registry.ForgeRegistries;
+import net.minecraft.resources.Identifier;
+import net.minecraftforge.registries.RegisterEvent;
+import net.minecraftforge.registries.MissingMappingsEvent;
+import net.minecraftforge.registries.ForgeRegistries;
import zone.moddev.mc.skysbuildingpiecesbop.basket.BasketData;
/** Recover retired BOP items without aliasing existing registry objects or losing multiplied output. */
-@Mod.EventBusSubscriber(
- modid = SkysBuildingPiecesBop.ID)
-public final class LegacyItemRecovery implements IFixableData {
+public final class LegacyItemRecovery {
private static final String[] OLD = { "biomesoplenty:turnip_seeds", "biomesoplenty:pinecone",
"biomesoplenty:dull_flower_band" };
- private static final String[] TARGET = { "harvestcraft:turnipseeditem", "biomesoplenty:brown_dye",
- "biomesoplenty:flower_0" };
+ private static final String[] TARGET = { "minecraft:beetroot_seeds", "minecraft:brown_dye",
+ "minecraft:oxeye_daisy" };
private static final int[] MULTIPLIER = { 1, 2, 8 };
public static final Item[] HOLDERS = new Item[3];
- static {
+ private static void createHolders() {
for (int holderIndex = 0; holderIndex < HOLDERS.length; holderIndex++) {
final int targetIndex = holderIndex;
- HOLDERS[holderIndex] = new Item() {
+ Identifier id = holderId(holderIndex);
+ HOLDERS[holderIndex] = new Item(SkysBuildingPiecesBop.itemProperties(id)) {
@Override
- public String getItemStackDisplayName(ItemStack stack) {
- Item target = ForgeRegistries.ITEMS.getValue(new ResourceLocation(TARGET[targetIndex]));
+ public net.minecraft.network.chat.Component getName(ItemStack stack) {
+ Item target = ForgeRegistries.ITEMS.getValue(Identifier.parse(TARGET[targetIndex]));
- return target == null || target == net.minecraft.init.Items.AIR ? "Retired BOP Item"
- : new ItemStack(target, 1, 0).getDisplayName();
+ return target == null || target == net.minecraft.world.item.Items.AIR
+ ? net.minecraft.network.chat.Component.translatable(
+ "item.skysbuildingpiecesbop.retired_item")
+ : new ItemStack(target).getHoverName();
}
- }.setRegistryName(SkysBuildingPiecesBop.ID, "legacy_" + OLD[holderIndex].substring(14));
+ };
}
}
- @Override
- public int getFixVersion() {
- return 2;
- }
-
- public IFixableData itemInstanceFix() {
- return new IFixableData() {
- @Override
- public int getFixVersion() {
- return 2;
- }
-
- @Override
- public NBTTagCompound fixTagCompound(NBTTagCompound stack) {
- // Vanilla may visit a child stack before our enclosing-inventory fix. Leave larger output intact until
- // that parent can allocate slots.
- if (index(stack.getString("id")) >= 0 && convert(stack).size() > 1) {
- return stack;
- }
-
- return LegacyItemRecovery.this.fixTagCompound(stack);
- }
- };
- }
-
- @Override
- public NBTTagCompound fixTagCompound(NBTTagCompound root) {
- if (!related(root, 0)) {
+ public CompoundTag fixTagCompound(CompoundTag root) {
+ // The core wraps saved staging states alone before vanilla can run a data fix.
+ if (isOldStagingGrass(root)) {
+ root.getCompoundOrEmpty("BlockState").putString("Name", "minecraft:short_grass");
+ return root;
+ }
+ if (!isRelevant(root)) {
return root;
}
- NBTTagCompound copy = root.copy();
+ CompoundTag copy = root.copy();
+ NamedBopRecovery.rewrite(copy);
+ LegacyPlantItems.rewrite(copy, false, 0);
rewrite(copy, 0, 0);
// Substitutions run before basket validation, including plants kept in old baskets.
new BasketData().fixTagCompound(copy);
- for (String key : copy.getKeySet()) {
- root.setTag(key, copy.getTag(key));
+ for (String key : copy.keySet()) {
+ root.put(key, copy.get(key));
}
return root;
@@ -88,7 +75,7 @@ public static String target(int index) {
private static int index(String legacyItemId) {
for (int legacyItemIndex = 0; legacyItemIndex < OLD.length; legacyItemIndex++) {
if (OLD[legacyItemIndex].equals(legacyItemId)
- || HOLDERS[legacyItemIndex].getRegistryName().toString().equals(legacyItemId)) {
+ || ForgeRegistries.ITEMS.getKey(HOLDERS[legacyItemIndex]).toString().equals(legacyItemId)) {
return legacyItemIndex;
}
}
@@ -96,23 +83,32 @@ private static int index(String legacyItemId) {
return -1;
}
- private static boolean related(NBTBase value, int depth) {
+ public boolean isRelevant(CompoundTag root) {
+ return isOldStagingGrass(root) || related(root, 0) || NamedBopRecovery.relevant(root);
+ }
+
+ private static boolean isOldStagingGrass(CompoundTag root) {
+ return root.size() == 1 && root.getCompoundOrEmpty("BlockState").getStringOr("Name", "").equals("minecraft:grass");
+ }
+
+ private static boolean related(Tag value, int depth) {
depth(depth);
- if (value instanceof NBTTagCompound) {
- NBTTagCompound compoundTag = (NBTTagCompound) value;
+ if (value instanceof CompoundTag) {
+ CompoundTag compoundTag = (CompoundTag) value;
- if (compoundTag.hasKey("id", 8)
- && (index(compoundTag.getString("id")) >= 0 || BasketData.isBasket(compoundTag.getString("id")))) {
+ if ((compoundTag.get("id") instanceof net.minecraft.nbt.StringTag)
+ && (index(compoundTag.getStringOr("id", "")) >= 0 || BasketData.isBasket(compoundTag.getStringOr("id", ""))
+ || LegacyPlantItems.matches(compoundTag.getStringOr("id", "")))) {
return true;
}
- for (String key : compoundTag.getKeySet()) {
- if (related(compoundTag.getTag(key), depth + 1)) {
+ for (String key : compoundTag.keySet()) {
+ if (related(compoundTag.get(key), depth + 1)) {
return true;
}
}
- } else if (value instanceof NBTTagList) {
- for (NBTBase child : (NBTTagList) value) {
+ } else if (value instanceof ListTag) {
+ for (Tag child : (ListTag) value) {
if (related(child, depth + 1)) {
return true;
}
@@ -122,14 +118,14 @@ private static boolean related(NBTBase value, int depth) {
return false;
}
- private static void rewrite(NBTBase value, int basketSlots, int depth) {
+ private static void rewrite(Tag value, int basketSlots, int depth) {
depth(depth);
- if (value instanceof NBTTagCompound) {
- NBTTagCompound compoundTag = (NBTTagCompound) value;
+ if (value instanceof CompoundTag) {
+ CompoundTag compoundTag = (CompoundTag) value;
- if (index(compoundTag.getString("id")) >= 0) {
- List parts = convert(compoundTag);
+ if (index(compoundTag.getStringOr("id", "")) >= 0) {
+ List parts = convert(compoundTag);
if (parts.size() != 1) {
throw invalid(
@@ -139,45 +135,48 @@ private static void rewrite(NBTBase value, int basketSlots, int depth) {
replace(compoundTag, parts.get(0));
}
- String registryId = compoundTag.getString("id");
+ String registryId = compoundTag.getStringOr("id", "");
if ((registryId.equals("ItemFrame") || registryId.equals("minecraft:item_frame"))
- && compoundTag.hasKey("Item", 10)
- && index(compoundTag.getCompoundTag("Item").getString("id")) >= 0) {
- int legacyItemIndex = index(compoundTag.getCompoundTag("Item").getString("id"));
+ && (compoundTag.get("Item") instanceof net.minecraft.nbt.CompoundTag)
+ && index(compoundTag.getCompoundOrEmpty("Item").getStringOr("id", "")) >= 0) {
+ int legacyItemIndex = index(compoundTag.getCompoundOrEmpty("Item").getStringOr("id", ""));
- if (compoundTag.getCompoundTag("Item").getInteger("Count") * MULTIPLIER[legacyItemIndex] > 1) {
+ if (BasketData.count(compoundTag.getCompoundOrEmpty("Item")) * MULTIPLIER[legacyItemIndex] > 1) {
throw invalid(
"remove the retired item from its item frame in the original world first; a frame cannot retain multiple replacement items");
}
}
- for (String key : compoundTag.getKeySet()) {
- NBTBase child = compoundTag.getTag(key);
+ if (BasketData.isBasket(registryId) && (compoundTag.get("components") instanceof net.minecraft.nbt.CompoundTag)) {
+ rewrite(BasketData.savedCustomData(compoundTag), BasketData.SIZE, depth + 1);
+ }
+ for (String key : compoundTag.keySet()) {
+ Tag child = compoundTag.get(key);
- if (child instanceof NBTTagList) {
- rewriteList((NBTTagList) child, capacity(compoundTag, key, basketSlots), depth + 1);
+ if (child instanceof ListTag) {
+ rewriteList((ListTag) child, capacity(compoundTag, key, basketSlots), depth + 1);
} else {
rewrite(child, key.equals("tag") && BasketData.isBasket(registryId) ? BasketData.SIZE : 0,
depth + 1);
}
}
- } else if (value instanceof NBTTagList) {
- rewriteList((NBTTagList) value, 0, depth + 1);
+ } else if (value instanceof ListTag) {
+ rewriteList((ListTag) value, 0, depth + 1);
}
}
- private static void rewriteList(NBTTagList list, int capacity, int depth) {
+ private static void rewriteList(ListTag list, int capacity, int depth) {
depth(depth);
BitSet occupied = new BitSet();
if (capacity > 0) {
- for (NBTBase value : list) {
- if (value instanceof NBTTagCompound) {
- NBTTagCompound compoundTag = (NBTTagCompound) value;
+ for (Tag value : list) {
+ if (value instanceof CompoundTag) {
+ CompoundTag compoundTag = (CompoundTag) value;
- if (compoundTag.hasKey("Slot", 99)) {
- int slot = compoundTag.getInteger("Slot");
+ if ((compoundTag.get("Slot") instanceof net.minecraft.nbt.NumericTag)) {
+ int slot = compoundTag.getIntOr("Slot", 0);
if (slot >= 0 && slot < capacity && !occupied.get(slot)) {
occupied.set(slot);
@@ -189,14 +188,14 @@ private static void rewriteList(NBTTagList list, int capacity, int depth) {
}
}
- List extras = new ArrayList();
+ List extras = new ArrayList();
- for (NBTBase value : list) {
- if (value instanceof NBTTagCompound && index(((NBTTagCompound) value).getString("id")) >= 0) {
- NBTTagCompound compoundTag = (NBTTagCompound) value;
- List parts = convert(compoundTag);
+ for (Tag value : list) {
+ if (value instanceof CompoundTag && index(((CompoundTag) value).getStringOr("id", "")) >= 0) {
+ CompoundTag compoundTag = (CompoundTag) value;
+ List parts = convert(compoundTag);
- if (parts.size() > 1 && (capacity == 0 || !compoundTag.hasKey("Slot", 99))) {
+ if (parts.size() > 1 && (capacity == 0 || !(compoundTag.get("Slot") instanceof net.minecraft.nbt.NumericTag))) {
throw invalid(
"a stack needs extra slots in an unknown inventory; split it in the original world first");
}
@@ -213,25 +212,25 @@ private static void rewriteList(NBTTagList list, int capacity, int depth) {
occupied.set(free);
- NBTTagCompound extra = parts.get(stackPartIndex);
+ CompoundTag extra = parts.get(stackPartIndex);
- extra.setByte("Slot", (byte) free);
+ extra.putByte("Slot", (byte) free);
extras.add(extra);
}
}
rewrite(value, 0, depth + 1);
}
- for (NBTTagCompound extra : extras) {
- list.appendTag(extra);
+ for (CompoundTag extra : extras) {
+ list.add(extra);
}
}
- private static int capacity(NBTTagCompound owner, String key, int basketSlots) {
+ private static int capacity(CompoundTag owner, String key, int basketSlots) {
if (key.equals("EnderItems")) {
return 27;
}
- if (key.equals("Inventory") && (owner.hasKey("abilities", 10) || owner.hasKey("EnderItems", 9))) {
+ if (key.equals("Inventory") && ((owner.get("abilities") instanceof net.minecraft.nbt.CompoundTag) || (owner.get("EnderItems") instanceof net.minecraft.nbt.ListTag))) {
return 36;
}
if (!key.equals("Items")) {
@@ -241,7 +240,7 @@ private static int capacity(NBTTagCompound owner, String key, int basketSlots) {
return basketSlots;
}
- String registryId = owner.getString("id");
+ String registryId = owner.getStringOr("id", "");
if (registryId.equals("Chest") || registryId.equals("minecraft:chest")
|| registryId.equals("minecraft:trapped_chest")) {
@@ -251,60 +250,69 @@ private static int capacity(NBTTagCompound owner, String key, int basketSlots) {
return 0;
}
- private static List convert(NBTTagCompound old) {
- int legacyItemIndex = index(old.getString("id"));
+ private static List convert(CompoundTag old) {
+ int legacyItemIndex = index(old.getStringOr("id", ""));
- if (!integer(old, "Count") || old.getInteger("Count") <= 0 || old.getInteger("Count") > 64
- || old.hasKey("Damage") && !integer(old, "Damage")) {
- throw invalid("invalid saved stack for " + old.getString("id"));
+ int savedCount = BasketData.count(old);
+ if (savedCount <= 0 || savedCount > 99
+ || old.contains("Damage") && !integer(old, "Damage")) {
+ throw invalid("invalid saved stack for " + old.getStringOr("id", ""));
}
- if (old.hasKey("tag") && !old.hasKey("tag", 10) || old.hasKey("ForgeCaps") && !old.hasKey("ForgeCaps", 10)) {
- throw invalid("malformed saved item data for " + old.getString("id"));
+ if (old.contains("tag") && !(old.get("tag") instanceof net.minecraft.nbt.CompoundTag)
+ || old.contains("ForgeCaps") && !(old.get("ForgeCaps") instanceof net.minecraft.nbt.CompoundTag)) {
+ throw invalid("malformed saved item data for " + old.getStringOr("id", ""));
}
- Item target = ForgeRegistries.ITEMS.getValue(new ResourceLocation(TARGET[legacyItemIndex]));
+ Item target = ForgeRegistries.ITEMS.getValue(Identifier.parse(TARGET[legacyItemIndex]));
- if (target == null || target == net.minecraft.init.Items.AIR) {
+ if (target == null || target == net.minecraft.world.item.Items.AIR) {
throw invalid("replacement " + TARGET[legacyItemIndex] + " is unavailable for " + OLD[legacyItemIndex]
+ "; install its original supported mod before upgrading");
}
- int remaining = old.getInteger("Count") * MULTIPLIER[legacyItemIndex];
- List output = new ArrayList();
+ boolean legacy = old.contains("Count");
+ int remaining = savedCount * MULTIPLIER[legacyItemIndex];
+ List output = new ArrayList();
while (remaining > 0) {
- NBTTagCompound part = old.copy();
+ CompoundTag part = old.copy();
- part.setString("id", TARGET[legacyItemIndex]);
- part.setShort("Damage", (short) 0);
+ part.putString("id", TARGET[legacyItemIndex]);
+ if (legacy) {
+ part.remove("Damage");
+ }
// A different item may not implement the old capabilities. Keep an exact archive rather than rely on its
// serializer retaining them.
- if (old.hasKey("ForgeCaps", 10)) {
- NBTTagCompound tag = part.getCompoundTag("tag");
+ if ((old.get("ForgeCaps") instanceof net.minecraft.nbt.CompoundTag)) {
+ CompoundTag tag = BasketData.savedCustomData(part);
String archive = SkysBuildingPiecesBop.ID + ":legacy_capabilities";
- if (tag.hasKey(archive)) {
+ if (tag.contains(archive)) {
throw invalid("existing legacy capability archive would be overwritten");
}
- tag.setTag(archive, old.getCompoundTag("ForgeCaps").copy());
- part.setTag("tag", tag);
- }
-
- ItemStack decoded = new ItemStack(target, 1, 0);
-
- if (part.hasKey("tag", 10)) {
- decoded.setTagCompound(part.getCompoundTag("tag").copy());
+ tag.put(archive, old.getCompoundOrEmpty("ForgeCaps").copy());
+ if (legacy) {
+ part.put("tag", tag);
+ } else {
+ CompoundTag components = part.getCompoundOrEmpty("components");
+ components.put("minecraft:custom_data", tag);
+ part.put("components", components);
+ }
}
- int count = Math.min(remaining, Math.min(64, decoded.getMaxStackSize()));
+ int count = Math.min(remaining, BasketData.stackLimit(part, target));
if (count <= 0) {
throw invalid("replacement has no usable stack capacity: " + TARGET[legacyItemIndex]);
}
- part.setByte("Count", (byte) count);
+ if (legacy) {
+ part.putByte("Count", (byte) count);
+ } else {
+ part.putInt("count", count);
+ }
output.add(part);
remaining -= count;
}
@@ -312,14 +320,14 @@ private static List convert(NBTTagCompound old) {
return output;
}
- private static void replace(NBTTagCompound targetData, NBTTagCompound from) {
- for (String key : from.getKeySet()) {
- targetData.setTag(key, from.getTag(key));
+ private static void replace(CompoundTag targetData, CompoundTag from) {
+ for (String key : from.keySet()) {
+ targetData.put(key, from.get(key));
}
}
- private static boolean integer(NBTTagCompound compoundTag, String key) {
- return compoundTag.hasKey(key, 99) && compoundTag.getTagId(key) >= 1 && compoundTag.getTagId(key) <= 3;
+ private static boolean integer(CompoundTag compoundTag, String key) {
+ return (compoundTag.get(key) instanceof net.minecraft.nbt.NumericTag) && compoundTag.get(key).getId() >= 1 && compoundTag.get(key).getId() <= 3;
}
private static void depth(int value) {
@@ -333,15 +341,21 @@ private static IllegalStateException invalid(String reason) {
+ ". Restore the original backup; no items may be discarded.");
}
- @SubscribeEvent
- public static void register(RegistryEvent.Register- event) {
- event.getRegistry().registerAll(HOLDERS);
+ public static void register(RegisterEvent event) {
+ if (!event.getRegistryKey().equals(ForgeRegistries.Keys.ITEMS)) return;
+ createHolders();
+ for (int index = 0; index < HOLDERS.length; index++) {
+ ForgeRegistries.ITEMS.register(holderId(index), HOLDERS[index]);
+ }
+ }
+
+ private static Identifier holderId(int index) {
+ return Identifier.fromNamespaceAndPath(SkysBuildingPiecesBop.ID, "legacy_" + Identifier.parse(OLD[index]).getPath());
}
- @SubscribeEvent
- public static void missing(RegistryEvent.MissingMappings
- event) {
- for (RegistryEvent.MissingMappings.Mapping
- mapping : event.getAllMappings()) {
- int legacyItemIndex = index(mapping.key.toString());
+ public static void missing(MissingMappingsEvent event) {
+ for (MissingMappingsEvent.Mapping
- mapping : event.getAllMappings(ForgeRegistries.Keys.ITEMS)) {
+ int legacyItemIndex = index(mapping.getKey().toString());
if (legacyItemIndex >= 0) {
mapping.remap(HOLDERS[legacyItemIndex]);
diff --git a/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/LegacyPlantItems.java b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/LegacyPlantItems.java
new file mode 100644
index 00000000..6a5b103d
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/LegacyPlantItems.java
@@ -0,0 +1,117 @@
+package zone.moddev.mc.skysbuildingpiecesbop;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.mojang.serialization.Dynamic;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtOps;
+import net.minecraft.SharedConstants;
+import net.minecraft.util.datafix.DataFixers;
+import net.minecraft.util.datafix.fixes.References;
+import zone.moddev.mc.skysbuildingpiecesbop.basket.BasketData;
+
+/** Update saved BOP item identities before a basket or container reads its inventory. */
+final class LegacyPlantItems {
+ private static final JsonObject PLANTS = readMappings();
+
+ private LegacyPlantItems() {
+ }
+
+ private static JsonObject readMappings() {
+ JsonObject mappings = read("legacy_plants.json");
+
+ for (java.util.Map.Entry entry : read("legacy_items.json").entrySet()) {
+ if (mappings.has(entry.getKey())) {
+ throw new IllegalStateException("Duplicate saved BOP item mapping: " + entry.getKey());
+ }
+
+ mappings.add(entry.getKey(), entry.getValue());
+ }
+
+ return mappings;
+ }
+
+ private static JsonObject read(String name) {
+ try (InputStreamReader reader = new InputStreamReader(
+ LegacyPlantItems.class.getResourceAsStream("/assets/skysbuildingpiecesbop/catalogue/" + name),
+ StandardCharsets.UTF_8)) {
+ return new Gson().fromJson(reader, JsonObject.class);
+ } catch (java.io.IOException failure) {
+ throw new IllegalStateException("Cannot read saved BOP item mappings: " + name, failure);
+ }
+ }
+
+ static boolean matches(String identifier) {
+ return PLANTS.has(identifier);
+ }
+
+ static void rewrite(Tag value, boolean basketContents, int depth) {
+ if (depth > 64) {
+ throw new IllegalStateException("Saved plant inventory nesting exceeds 64 levels");
+ }
+ if (value instanceof CompoundTag) {
+ CompoundTag compound = (CompoundTag) value;
+ String identifier = compound.getStringOr("id", "");
+ boolean basket = BasketData.isBasket(identifier);
+ String nativeTarget = null;
+
+ if (basketContents && identifier.equals("minecraft:grass") && (compound.get("Count") instanceof net.minecraft.nbt.NumericTag)) {
+ compound.putString("id", "minecraft:short_grass");
+ }
+
+ if ((compound.get("Count") instanceof net.minecraft.nbt.NumericTag) && (compound.get("Damage") instanceof net.minecraft.nbt.NumericTag)) {
+ if (matches(identifier)) {
+ JsonObject variants = PLANTS.getAsJsonObject(identifier);
+ String metadata = Integer.toString(compound.getIntOr("Damage", 0));
+
+ if (!variants.has(metadata)) {
+ throw new IllegalStateException(
+ "No safe saved plant target for " + identifier + "/" + metadata);
+ }
+
+ nativeTarget = NamedBopRecovery.currentMapleItem(variants.get(metadata).getAsString());
+ if (nativeTarget.equals("minecraft:grass")) {
+ nativeTarget = "minecraft:short_grass";
+ }
+ compound.putString("id", nativeTarget);
+ compound.remove("Damage");
+
+ if (!basketContents && nativeTarget.equals("minecraft:short_grass")) {
+ compound.putString(zone.moddev.mc.skysbuildingpieces.legacy.HistoricalItems.NATIVE_ITEM_ID,
+ nativeTarget);
+ }
+ }
+ if (basketContents && (identifier.startsWith("minecraft:") || matches(identifier))) {
+ // Call vanilla directly. NBTUtil is already wrapped by the core's early inventory hook, so entering
+ // it here would recurse.
+ CompoundTag fixed = (CompoundTag) DataFixers.getDataFixer()
+ .update(References.ITEM_STACK, new Dynamic<>(NbtOps.INSTANCE, compound.copy()),
+ 1343, SharedConstants.getCurrentVersion().dataVersion().version())
+ .getValue();
+
+ for (String key : new ArrayList<>(compound.keySet())) {
+ compound.remove(key);
+ }
+ for (String key : fixed.keySet()) {
+ compound.put(key, fixed.get(key));
+ }
+ if (nativeTarget != null) {
+ compound.putString("id", nativeTarget);
+ }
+ }
+ }
+ for (String key : new ArrayList<>(compound.keySet())) {
+ rewrite(compound.get(key), basketContents || basket && key.equals("tag"), depth + 1);
+ }
+ } else if (value instanceof ListTag) {
+ for (Tag child : (ListTag) value) {
+ rewrite(child, basketContents, depth + 1);
+ }
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/MissingMappingEvents.java b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/MissingMappingEvents.java
new file mode 100644
index 00000000..516de5a1
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/MissingMappingEvents.java
@@ -0,0 +1,23 @@
+package zone.moddev.mc.skysbuildingpiecesbop;
+
+import net.minecraft.world.item.Item;
+import net.minecraft.world.level.block.Block;
+import net.minecraftforge.registries.MissingMappingsEvent;
+import net.minecraftforge.registries.ForgeRegistries;
+import zone.moddev.mc.skysbuildingpiecesbop.basket.Baskets;
+
+/** Forge posts missing mappings on its gameplay bus, after registration. */
+public final class MissingMappingEvents {
+ private MissingMappingEvents() {
+ }
+
+ public static void missing(MissingMappingsEvent event) {
+ if (event.getKey().equals(ForgeRegistries.Keys.BLOCKS)) {
+ RetiredMaterialHolders.missingBlocks(event);
+ } else if (event.getKey().equals(ForgeRegistries.Keys.ITEMS)) {
+ RetiredMaterialHolders.missingItems(event);
+ LegacyItemRecovery.missing(event);
+ Baskets.missing(event);
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/NamedBopRecovery.java b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/NamedBopRecovery.java
new file mode 100644
index 00000000..33a5cb7b
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/NamedBopRecovery.java
@@ -0,0 +1,789 @@
+package zone.moddev.mc.skysbuildingpiecesbop;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.NavigableMap;
+import java.util.TreeMap;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.Items;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.nbt.Tag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtUtils;
+import net.minecraft.nbt.StringTag;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.level.block.state.properties.Property;
+import net.minecraft.resources.Identifier;
+import net.minecraft.SharedConstants;
+import net.minecraft.core.Registry;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraftforge.registries.MissingMappingsEvent;
+import net.minecraftforge.registries.ForgeRegistries;
+
+/** Recovers named BOP content removed after flattening, before native loaders read it. */
+public final class NamedBopRecovery {
+ private static final Map RECENT_BLOCKS = mappings("renamed_1_21_11");
+ private static final Map NATIVE_RENAMES = mappings("renamed_26_2");
+ private static final Map MAPLE_RENAMES = mappings("renamed_26_3");
+ private static final Map BLOCKS = blockMappings();
+ private static final Map ITEMS = itemMappings();
+ private static final Map BIOMES = mappings("biomes");
+ private static final Map SECTION_BIOMES = mappings("section_biomes");
+ private static final Set OLD_BOATS = Set.of("biomesoplenty:boat", "biomesoplenty:chest_boat");
+ private static final Set BOAT_WOODS = Set.of("fir", "pine", "maple", "redwood", "mahogany", "jacaranda",
+ "palm", "willow", "dead", "magic", "umbran", "hellbark", "empyreal");
+ private static final Set IMPLICIT_ITEM_KEYS = Set.of("id", "components", "Slot");
+ private static final int SECTION_BIOMES_RELEASE = 2860;
+ private static final String BIOME_MARKER = "skysbuildingpiecesbop_named_biomes";
+ private static volatile NavigableMap> biomeSnapshots = new TreeMap<>();
+
+ private NamedBopRecovery() {
+ }
+
+ private static Map mappings(String group) {
+ String path = "/assets/skysbuildingpiecesbop/catalogue/named_legacy.json";
+
+ try (InputStreamReader reader = new InputStreamReader(
+ NamedBopRecovery.class.getResourceAsStream(path), StandardCharsets.UTF_8)) {
+ JsonObject data = new Gson().fromJson(reader, JsonObject.class).getAsJsonObject(group);
+ Map result = new LinkedHashMap<>();
+
+ data.entrySet().forEach(entry -> result.put(entry.getKey(), entry.getValue().getAsString()));
+ return Collections.unmodifiableMap(result);
+ } catch (IOException failure) {
+ throw new IllegalStateException("Cannot read named BOP upgrade mappings", failure);
+ }
+ }
+
+ private static Map itemMappings() {
+ Map result = new LinkedHashMap<>(BLOCKS);
+
+ // The wall sign only existed as a placed block. Its item is cherry_sign.
+ result.remove("biomesoplenty:cherry_wall_sign");
+ result.putAll(mappings("items"));
+ // The planted sapling has a block ID but no corresponding vanilla item.
+ result.replaceAll((source, target) -> target.equals("minecraft:potted_poplar_sapling")
+ ? "minecraft:poplar_sapling" : target);
+ return Collections.unmodifiableMap(result);
+ }
+
+ private static Map blockMappings() {
+ Map result = new LinkedHashMap<>(mappings("blocks"));
+ result.putAll(RECENT_BLOCKS);
+ result.putAll(NATIVE_RENAMES);
+ // Resolve older maple names straight to their current blocks.
+ result.replaceAll((source, target) -> MAPLE_RENAMES.getOrDefault(target, target));
+ result.putAll(MAPLE_RENAMES);
+ return Collections.unmodifiableMap(result);
+ }
+
+ static String currentMapleBlock(String id) {
+ return MAPLE_RENAMES.getOrDefault(id, id);
+ }
+
+ static String currentMapleItem(String id) {
+ return MAPLE_RENAMES.containsKey(id) ? ITEMS.get(id) : id;
+ }
+
+ private static boolean namedItem(CompoundTag tag) {
+ String name = tag.getStringOr("id", "");
+ if (!ITEMS.containsKey(name)) {
+ return false;
+ }
+ if (tag.get("Count") instanceof net.minecraft.nbt.NumericTag
+ || tag.get("count") instanceof net.minecraft.nbt.NumericTag) {
+ return true;
+ }
+ // Modern stacks can omit a count of one. Limit that form to the names
+ // known to have been items in the preceding BOP release.
+ return (RECENT_BLOCKS.containsKey(name) || NATIVE_RENAMES.containsKey(name) || MAPLE_RENAMES.containsKey(name))
+ && IMPLICIT_ITEM_KEYS.containsAll(tag.keySet());
+ }
+
+ private static boolean namedBoat(CompoundTag tag) {
+ return OLD_BOATS.contains(tag.getStringOr("id", "")) && tag.getList("Pos").isPresent()
+ && tag.getIntArray("UUID").isPresent();
+ }
+
+ private static void rewriteBoat(CompoundTag boat) {
+ String source = boat.getStringOr("id", "");
+ String wood = boat.getStringOr("model", "");
+ String shape = source.equals("biomesoplenty:chest_boat") ? "_chest_boat" : "_boat";
+ Identifier target = Identifier.fromNamespaceAndPath("biomesoplenty", wood + shape);
+ if (!BOAT_WOODS.contains(wood) || !ForgeRegistries.ENTITY_TYPES.containsKey(target)) {
+ throw new IllegalStateException("No replacement for " + source + " with saved wood type " + wood);
+ }
+ // New boat types encode the material in the registry ID. Keep the UUID, chest, and passengers.
+ boat.putString("id", target.toString());
+ boat.remove("model");
+ }
+
+ /** Keep earlier numeric biome tables available for chunks not opened during the first upgrade. */
+ public static synchronized void begin(MinecraftServer server) throws IOException {
+ File level = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.LEVEL_DATA_FILE).toFile();
+ File sidecar = new File(level.getParentFile(), "data/skysbuildingpiecesbop_named_registry.dat");
+ CompoundTag saved = new CompoundTag();
+
+ saved.putInt("schema_version", 1);
+ if (sidecar.isFile()) {
+ try (FileInputStream input = new FileInputStream(sidecar)) {
+ saved = NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap());
+ }
+ if (saved.getIntOr("schema_version", 0) != 1) {
+ throw new IllegalStateException("Unsupported named BOP registry snapshot schema");
+ }
+ }
+
+ CompoundTag snapshots = saved.getCompoundOrEmpty("snapshots");
+ boolean changed = false;
+
+ CompoundTag original = zone.moddev.mc.skysbuildingpieces.legacy.LegacyBridge.sourceBiomeSnapshot(server);
+
+ if (original.isEmpty() && level.isFile()) {
+ CompoundTag root;
+
+ try (FileInputStream input = new FileInputStream(level)) {
+ root = NbtIo.readCompressed(input, net.minecraft.nbt.NbtAccounter.unlimitedHeap());
+ }
+
+ original.putInt("DataVersion", root.getCompoundOrEmpty("Data").getIntOr("DataVersion", 0));
+ CompoundTag registries = root.getCompoundOrEmpty("fml").getCompoundOrEmpty("Registries");
+ CompoundTag registry = registries.getCompoundOrEmpty("minecraft:worldgen/biome");
+ if (registry.isEmpty()) {
+ registry = registries.getCompoundOrEmpty("minecraft:biome");
+ }
+ original.put("Registry", registry.copy());
+ }
+
+ int sourceVersion = original.getIntOr("DataVersion", 0);
+
+ if (sourceVersion >= 1451 && sourceVersion < SharedConstants.getCurrentVersion().dataVersion().version()) {
+ CompoundTag registry = original.getCompoundOrEmpty("Registry");
+ Tag ids = registry.get("ids");
+ // Named sections can leave Forge's numeric registry empty. Keep
+ // older sidecar tables for unopened chunks that still use numbers.
+ boolean namedOnly = sourceVersion >= SECTION_BIOMES_RELEASE
+ && ids instanceof ListTag entries && entries.isEmpty();
+
+ if (ids instanceof ListTag && !namedOnly) {
+ String key = Integer.toString(sourceVersion);
+
+ if ((snapshots.get(key) instanceof net.minecraft.nbt.CompoundTag) && !snapshots.getCompoundOrEmpty(key).equals(registry)) {
+ throw new IllegalStateException("Conflicting BOP biome tables for save version " + sourceVersion
+ + ". Restore the original world backup and mod list before upgrading.");
+ }
+ if (!(snapshots.get(key) instanceof net.minecraft.nbt.CompoundTag)) {
+ snapshots.put(key, registry.copy());
+ changed = true;
+ }
+ }
+ }
+
+ NavigableMap> resolved = new TreeMap<>();
+
+ for (String version : snapshots.keySet()) {
+ resolved.put(Integer.parseInt(version), resolveBiomes(snapshots.getCompoundOrEmpty(version)));
+ }
+ if (changed) {
+ saved.put("snapshots", snapshots);
+ writeSnapshot(sidecar.toPath(), saved);
+ }
+ biomeSnapshots = resolved;
+ }
+
+ private static Registry biomeRegistry() {
+ // The active world's registry owns the saved numeric IDs, not Forge's registration objects.
+ return net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer().registryAccess()
+ .lookupOrThrow(net.minecraft.core.registries.Registries.BIOME);
+ }
+
+ private static Map resolveBiomes(CompoundTag registry) {
+ Map result = new LinkedHashMap<>();
+ ListTag entries = registry.getListOrEmpty("ids");
+ java.util.Set numbers = new java.util.HashSet<>();
+ java.util.Set names = new java.util.HashSet<>();
+
+ if (entries.isEmpty() || !(registry.get("ids") instanceof ListTag)
+ || ((ListTag) registry.get("ids")).size() != entries.size()) {
+ throw new IllegalStateException("Malformed saved BOP biome registry table");
+ }
+
+ for (int index = 0; index < entries.size(); index++) {
+ CompoundTag entry = entries.getCompoundOrEmpty(index);
+ String source = entry.getStringOr("K", "");
+ int oldId = entry.getIntOr("V", 0);
+
+ if (!(entry.get("K") instanceof net.minecraft.nbt.StringTag) || !(entry.get("V") instanceof net.minecraft.nbt.IntTag) || oldId < 0
+ || !numbers.add(oldId) || !names.add(source)) {
+ throw new IllegalStateException("Invalid or duplicate saved biome identity: " + entry);
+ }
+
+ if (!source.startsWith("biomesoplenty:")) {
+ continue;
+ }
+
+ String target = BIOMES.getOrDefault(source, source);
+ Biome biome = biomeRegistry().getValue(Identifier.parse(target));
+
+ if (biome == null) {
+ throw new IllegalStateException("No safe named BOP biome replacement for " + source);
+ }
+
+ result.put(oldId, target);
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ private static void writeSnapshot(Path target, CompoundTag saved) throws IOException {
+ Files.createDirectories(target.getParent());
+
+ Path temporary = Files.createTempFile(target.getParent(), ".bop-named-registry-", ".tmp");
+
+ try {
+ try (FileOutputStream output = new FileOutputStream(temporary.toFile())) {
+ NbtIo.writeCompressed(saved, output);
+ }
+ try {
+ Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException unsupported) {
+ Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ } finally {
+ Files.deleteIfExists(temporary);
+ }
+ }
+
+ static boolean relevant(Tag value) {
+ return relevant(value, 0, false);
+ }
+
+ private static boolean relevant(Tag value, int depth, boolean blockState) {
+ depth(depth);
+ if (blockState && (BLOCKS.containsKey(savedBlockName(value))
+ || RetiredRocks.LEGACY_ID.equals(savedBlockName(value)))) {
+ return true;
+ }
+ if (value instanceof CompoundTag) {
+ CompoundTag tag = (CompoundTag) value;
+
+ if (blockState && (oldShortCattail(tag)
+ || oldWall(savedBlockName(tag), tag.getCompoundOrEmpty(propertiesKey(tag))))
+ || (!blockState && namedItem(tag)) || (tag.get("Count") instanceof net.minecraft.nbt.NumericTag)
+ && oldWall(tag.getStringOr("id", ""), tag.getCompoundOrEmpty("tag").getCompoundOrEmpty("BlockStateTag"))
+ || namedBoat(tag) || biomeTable(tag) != null || removedSectionBiomes(tag)
+ || retiredBlockTicks(tag, false)) {
+ return true;
+ }
+ for (String key : tag.keySet()) {
+ if (relevant(tag.get(key), depth + 1, stateField(key))) {
+ return true;
+ }
+ }
+ } else if (value instanceof ListTag) {
+ for (Tag child : (ListTag) value) {
+ if (relevant(child, depth + 1, blockState)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ static void rewrite(CompoundTag root) {
+ rewrite(root, 0, false);
+ }
+
+ private static Tag rewrite(Tag value, int depth, boolean blockState) {
+ depth(depth);
+ if (blockState && value instanceof StringTag compact) {
+ if (RetiredRocks.LEGACY_ID.equals(compact.value())) {
+ return StringTag.valueOf(RetiredRocks.id(0).toString());
+ }
+ String replacement = BLOCKS.get(compact.value());
+ if (replacement != null) {
+ CompoundTag state = new CompoundTag();
+ state.putString("id", compact.value());
+ rewriteState(state, replacement);
+ return state;
+ }
+ }
+ if (value instanceof CompoundTag tag) {
+ String name = blockState ? savedBlockName(tag) : "";
+ String propertiesKey = propertiesKey(tag);
+
+ ListTag sections = chunkSections(tag);
+ retiredBlockTicks(tag, true);
+ retargetCherrySigns(tag, sections);
+ for (Tag entry : sections) {
+ CompoundTag section = (CompoundTag) entry;
+ if (!removedSectionBiome(section)) {
+ continue;
+ }
+ // Section palettes already store names. Keep their indices and packed cells unchanged.
+ ListTag palette = section.getCompoundOrEmpty("biomes").getListOrEmpty("palette");
+ for (int index = 0; index < palette.size(); index++) {
+ String replacement = SECTION_BIOMES.get(palette.getStringOr(index, ""));
+ if (replacement != null) {
+ palette.set(index, net.minecraft.nbt.StringTag.valueOf(replacement));
+ }
+ }
+ }
+
+ if (RetiredRocks.LEGACY_ID.equals(name)) {
+ if (tag.contains(propertiesKey) && !(tag.get(propertiesKey) instanceof CompoundTag)) {
+ throw new IllegalStateException("Malformed saved retired rock properties: " + tag);
+ }
+ CompoundTag properties = tag.getCompoundOrEmpty(propertiesKey);
+ int slot = properties.contains("variant") ? Integer.parseInt(properties.getStringOr("variant", "")) : 0;
+ if (slot < 0 || slot >= RetiredRocks.NAMES.length) {
+ throw new IllegalStateException("Unassigned saved retired rock: " + tag);
+ }
+
+ String nameKey = tag.contains("Name") ? "Name" : tag.contains("id") ? "id" : "";
+ tag.putString(nameKey, RetiredRocks.id(slot).toString());
+ tag.remove(propertiesKey);
+ } else if (blockState && name.equals("biomesoplenty:nether_crystal")) {
+ if (tag.contains(propertiesKey) && !(tag.get(propertiesKey) instanceof net.minecraft.nbt.CompoundTag)) {
+ throw new IllegalStateException("Malformed saved nether crystal properties: " + tag);
+ }
+ CompoundTag candidate = tag.copy();
+ candidate.put(propertiesKey, crystalProperties(tag.getCompoundOrEmpty(propertiesKey)));
+ rewriteState(candidate, BLOCKS.get(name));
+ return candidate;
+ } else if (blockState && oldWall(name, tag.getCompoundOrEmpty(propertiesKey))) {
+ rewriteWall(tag.getCompoundOrEmpty(propertiesKey));
+ rewriteState(tag, BLOCKS.getOrDefault(name, name));
+ } else if (blockState && oldShortCattail(tag)) {
+ rewriteState(tag, "biomesoplenty:dead_grass");
+ } else if (blockState && BLOCKS.containsKey(name)) {
+ rewriteState(tag, BLOCKS.get(name));
+ }
+ if (namedBoat(tag)) {
+ rewriteBoat(tag);
+ }
+ if (!blockState && namedItem(tag)) {
+ if (tag.getStringOr("id", "").equals("biomesoplenty:nether_crystal")) {
+ CompoundTag itemData = tag.getCompoundOrEmpty("tag");
+ if (itemData.contains("BlockStateTag")) {
+ if (!(itemData.get("BlockStateTag") instanceof net.minecraft.nbt.CompoundTag)) {
+ throw new IllegalStateException("Malformed nether crystal item properties: " + tag);
+ }
+ itemData.put("BlockStateTag", crystalProperties(itemData.getCompoundOrEmpty("BlockStateTag")));
+ }
+ }
+ String target = ITEMS.get(tag.getStringOr("id", ""));
+ Item item = ForgeRegistries.ITEMS.getValue(Identifier.parse(target));
+
+ if (item == null || item == Items.AIR) {
+ throw new IllegalStateException("Missing named BOP item replacement: " + target);
+ }
+ tag.putString("id", target);
+ }
+
+ if ((tag.get("Count") instanceof net.minecraft.nbt.NumericTag)
+ && oldWall(tag.getStringOr("id", ""), tag.getCompoundOrEmpty("tag").getCompoundOrEmpty("BlockStateTag"))) {
+ rewriteWall(tag.getCompoundOrEmpty("tag").getCompoundOrEmpty("BlockStateTag"));
+ }
+
+ Map biomes = biomeTable(tag);
+
+ if (biomes != null) {
+ CompoundTag level = tag.getCompoundOrEmpty("Level");
+ int[] values = level.getIntArray("Biomes").orElseThrow();
+ int[] originalValues = values.clone();
+ Map currentIds = new LinkedHashMap<>();
+
+ if (values.length != 256 && values.length != 1024) {
+ throw new IllegalStateException("Malformed saved BOP biome array: " + values.length + " cells");
+ }
+
+ // Forge restores a world's numeric registry after AboutToStart. Resolve names at the chunk read.
+ biomes.forEach((source, target) -> {
+ Biome biome = biomeRegistry().getValue(Identifier.parse(target));
+ int currentId = biome == null ? -1 : biomeRegistry().getId(biome);
+
+ if (currentId < 0) {
+ throw new IllegalStateException("Missing current BOP biome replacement: " + target);
+ }
+ currentIds.put(source, currentId);
+ });
+
+ for (int index = 0; index < values.length; index++) {
+ values[index] = currentIds.getOrDefault(values[index], values[index]);
+ }
+ level.putIntArray("Biomes", values);
+ zone.moddev.mc.skysbuildingpieces.legacy.HistoricalBiomes.preserveNamed(tag, originalValues, biomes);
+ // The early inventory wrapper can see the same tag again before vanilla updates DataVersion.
+ level.putInt(BIOME_MARKER, SharedConstants.getCurrentVersion().dataVersion().version());
+ }
+ for (String key : new ArrayList<>(tag.keySet())) {
+ Tag child = tag.get(key);
+ Tag replacement = rewrite(child, depth + 1, stateField(key));
+ if (child != replacement) {
+ tag.put(key, replacement);
+ }
+ }
+ } else if (value instanceof ListTag list) {
+ for (int index = 0; index < list.size(); index++) {
+ Tag child = list.get(index);
+ Tag replacement = rewrite(child, depth + 1, blockState);
+ if (child != replacement) {
+ list.set(index, replacement);
+ }
+ }
+ }
+ return value;
+ }
+
+ private static boolean oldWall(String name, CompoundTag properties) {
+ if (!name.equals("biomesoplenty:white_sandstone_wall") && !name.equals("biomesoplenty:mud_brick_wall")
+ && !name.equals("minecraft:mud_brick_wall")) {
+ return false;
+ }
+ for (String direction : new String[] {"north", "south", "east", "west"}) {
+ String value = properties.getStringOr(direction, "");
+ if (value.equals("true") || value.equals("false")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void retargetCherrySigns(CompoundTag chunk, ListTag sections) {
+ if (sections.isEmpty()) {
+ return;
+ }
+ for (Tag entry : chunk.getListOrEmpty("block_entities")) {
+ CompoundTag sign = (CompoundTag) entry;
+ if (!sign.getStringOr("id", "").equals("biomesoplenty:sign")) {
+ continue;
+ }
+ int x = sign.getIntOr("x", 0);
+ int y = sign.getIntOr("y", 0);
+ int z = sign.getIntOr("z", 0);
+ for (Tag sectionEntry : sections) {
+ CompoundTag section = (CompoundTag) sectionEntry;
+ if (section.getByteOr("Y", (byte) 0) != (y >> 4)) {
+ continue;
+ }
+ String block = savedSignBlock(section.getCompoundOrEmpty("block_states"), x, y, z);
+ if (isCherrySign(block)) {
+ // Vanilla's sign fixer converts the old text, filtering,
+ // dye and glow once the block entity has the vanilla ID.
+ sign.putString("id", "minecraft:sign");
+ }
+ break;
+ }
+ }
+ }
+
+ private static String savedSignBlock(CompoundTag states, int x, int y, int z) {
+ ListTag palette = states.getListOrEmpty("palette");
+ if (palette.stream().map(NamedBopRecovery::savedBlockName)
+ .noneMatch(NamedBopRecovery::isCherrySign)) {
+ return "";
+ }
+ if (palette.size() == 1) {
+ return savedBlockName(palette.get(0));
+ }
+ int bits = Math.max(4, Integer.SIZE - Integer.numberOfLeadingZeros(palette.size() - 1));
+ int entriesPerWord = Long.SIZE / bits;
+ int index = ((y & 15) << 8) | ((z & 15) << 4) | (x & 15);
+ long[] storage = states.getLongArray("data").orElseGet(() -> new long[0]);
+ if (index / entriesPerWord >= storage.length) {
+ throw new IllegalStateException("Incomplete saved block palette for BOP sign at " + x + ", " + y + ", " + z);
+ }
+ int paletteIndex = (int) (storage[index / entriesPerWord] >>> (index % entriesPerWord * bits) & ((1L << bits) - 1));
+ if (paletteIndex >= palette.size()) {
+ throw new IllegalStateException("Invalid saved block palette index for BOP sign at " + x + ", " + y + ", " + z);
+ }
+ return savedBlockName(palette.get(paletteIndex));
+ }
+
+ private static boolean isCherrySign(String block) {
+ return block.equals("biomesoplenty:cherry_sign") || block.equals("biomesoplenty:cherry_wall_sign");
+ }
+
+ static CompoundTag crystalProperties(CompoundTag original) {
+ for (String property : original.keySet()) {
+ if ((!property.equals("face") && !property.equals("facing")) || !(original.get(property) instanceof net.minecraft.nbt.StringTag)) {
+ throw new IllegalStateException("Cannot recover nether crystal property " + property + ": " + original);
+ }
+ }
+ String face = original.contains("face") ? original.getStringOr("face", "") : "floor";
+ String facing = original.contains("facing") ? original.getStringOr("facing", "") : "north";
+ if (!facing.equals("north") && !facing.equals("south") && !facing.equals("east") && !facing.equals("west")) {
+ throw new IllegalStateException("Cannot recover nether crystal direction: " + facing);
+ }
+ String target;
+ if (face.equals("floor")) {
+ target = "up";
+ } else if (face.equals("ceiling")) {
+ target = "down";
+ } else if (face.equals("wall")) {
+ target = facing;
+ } else {
+ throw new IllegalStateException("Cannot recover nether crystal attachment: " + face);
+ }
+ // The old floor and ceiling rotations use identical geometry. The new
+ // cluster expresses its attachment with one six-direction property.
+ CompoundTag result = new CompoundTag();
+ result.putString("facing", target);
+ result.putString("waterlogged", "false");
+ return result;
+ }
+
+ private static void rewriteWall(CompoundTag properties) {
+ // Vanilla only upgrades its own walls. Keep BOP's old arms, post and water state too.
+ for (String direction : new String[] {"north", "south", "east", "west"}) {
+ if (!properties.contains(direction)) {
+ continue;
+ }
+ String value = properties.getStringOr(direction, "");
+ if (value.equals("true") || value.equals("false")) {
+ properties.putString(direction, value.equals("true") ? "low" : "none");
+ } else if (!value.equals("low") && !value.equals("none") && !value.equals("tall")) {
+ throw new IllegalStateException("Cannot recover BOP wall connection " + direction + "=" + value);
+ }
+ }
+ }
+
+ private static boolean oldShortCattail(CompoundTag state) {
+ // BOP reused this ID for the tall plant. The old one-block state has no half property.
+ return state.getStringOr("Name", "").equals("biomesoplenty:cattail")
+ && !state.getCompoundOrEmpty("Properties").contains("half");
+ }
+
+ private static String savedBlockName(Tag state) {
+ if (state instanceof StringTag name) {
+ return name.value();
+ }
+ if (state instanceof CompoundTag tag) {
+ // Default states can be stored as strings, wrapped when mixed with compounds.
+ return tag.getStringOr("Name", tag.getStringOr("id", tag.getStringOr("", "")));
+ }
+ return "";
+ }
+
+ private static String propertiesKey(CompoundTag state) {
+ return state.contains("Name") ? "Properties" : "properties";
+ }
+
+ private static boolean stateField(String key) {
+ // A custom display name can also be a registry-looking string. Only native state fields identify blocks.
+ return key.equals("Palette") || key.equals("palette") || key.equals("palettes") || key.equals("BlockState")
+ || key.equals("blockState") || key.equals("block_state") || key.equals("carriedBlockState") || key.equals("DisplayState");
+ }
+
+ private static ListTag chunkSections(CompoundTag chunk) {
+ if (!(chunk.get("DataVersion") instanceof net.minecraft.nbt.NumericTag) || !(chunk.get("xPos") instanceof net.minecraft.nbt.NumericTag)
+ || !(chunk.get("zPos") instanceof net.minecraft.nbt.NumericTag)) {
+ return new ListTag();
+ }
+ return chunk.getListOrEmpty("sections");
+ }
+
+ private static boolean retiredBlockTicks(CompoundTag chunk, boolean update) {
+ if (!(chunk.get("xPos") instanceof net.minecraft.nbt.NumericTag)
+ || !(chunk.get("zPos") instanceof net.minecraft.nbt.NumericTag)) {
+ return false;
+ }
+ boolean changed = false;
+ for (String key : new String[] { "block_ticks", "TileTicks" }) {
+ for (Tag value : chunk.getListOrEmpty(key)) {
+ if (!(value instanceof CompoundTag tick)) {
+ continue;
+ }
+ String replacement = BLOCKS.get(tick.getStringOr("i", ""));
+ if (replacement != null) {
+ if (!update) {
+ return true;
+ }
+ tick.putString("i", replacement);
+ changed = true;
+ }
+ }
+ }
+ return changed;
+ }
+
+ private static boolean removedSectionBiomes(CompoundTag chunk) {
+ for (Tag entry : chunkSections(chunk)) {
+ if (removedSectionBiome((CompoundTag) entry)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean removedSectionBiome(CompoundTag section) {
+ if (!(section.get("Y") instanceof net.minecraft.nbt.ByteTag) || !(section.get("biomes") instanceof net.minecraft.nbt.CompoundTag)) {
+ return false;
+ }
+ ListTag palette = section.getCompoundOrEmpty("biomes").getListOrEmpty("palette");
+ for (int index = 0; index < palette.size(); index++) {
+ if (SECTION_BIOMES.containsKey(palette.getStringOr(index, ""))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Map biomeTable(CompoundTag root) {
+ int version = root.getIntOr("DataVersion", 0);
+ CompoundTag level = root.getCompoundOrEmpty("Level");
+
+ if (version < 1451 || !(level.get("Biomes") instanceof net.minecraft.nbt.IntArrayTag)) {
+ return null;
+ }
+
+ int current = SharedConstants.getCurrentVersion().dataVersion().version();
+
+ if (version >= current || level.getIntOr(BIOME_MARKER, 0) >= current) {
+ return null;
+ }
+
+ Map.Entry> snapshot = biomeSnapshots.ceilingEntry(version);
+
+ return snapshot == null || snapshot.getValue().isEmpty() ? null : snapshot.getValue();
+ }
+
+ private static void rewriteState(CompoundTag saved, String target) {
+ Block block = ForgeRegistries.BLOCKS.getValue(Identifier.parse(target));
+
+ if (block == null || block == Blocks.AIR) {
+ throw new IllegalStateException("Missing named BOP block replacement: " + target);
+ }
+
+ BlockState state = block.defaultBlockState();
+ String propertiesKey = propertiesKey(saved);
+ CompoundTag properties = saved.getCompoundOrEmpty(propertiesKey);
+
+ if (saved.contains(propertiesKey) && !(saved.get(propertiesKey) instanceof CompoundTag)) {
+ throw new IllegalStateException("Malformed saved BOP block properties: " + saved);
+ }
+ String source = savedBlockName(saved);
+ if (RECENT_BLOCKS.containsKey(source) && source.endsWith("_maple_leaf_pile")) {
+ // The old pile covered the whole block. A single litter segment would leave most of it bare.
+ Property> amount = block.getStateDefinition().getProperty("segment_amount");
+ if (amount == null || !properties.isEmpty()) {
+ throw new IllegalStateException("Cannot recover the maple leaf pile: " + saved);
+ }
+ state = withProperty(state, amount, "4");
+ }
+ for (String name : properties.keySet()) {
+ if (name.equals("distance")
+ && (source.equals("biomesoplenty:thermal_calcite")
+ || source.equals("biomesoplenty:thermal_calcite_vent"))) {
+ // Sphalerite no longer colours its surface by distance from a vent.
+ String distance = properties.getStringOr(name, "");
+ if (!distance.matches("[1-5]")) {
+ throw new IllegalStateException("Invalid thermal calcite distance: " + saved);
+ }
+ continue;
+ }
+ Property> property = block.getStateDefinition().getProperty(name);
+
+ if (property == null || !(properties.get(name) instanceof net.minecraft.nbt.StringTag)) {
+ throw new IllegalStateException("No safe " + name + " property on replacement " + target);
+ }
+ state = withProperty(state, property, properties.getString(name).orElseThrow());
+ }
+ // BOP still has native double slabs for its renamed rocks. Keep their saved shape.
+ if (!NATIVE_RENAMES.containsKey(source)
+ && state.hasProperty(net.minecraft.world.level.block.SlabBlock.TYPE)
+ && state.getValue(net.minecraft.world.level.block.SlabBlock.TYPE) == net.minecraft.world.level.block.state.properties.SlabType.DOUBLE) {
+ if (state.getValue(net.minecraft.world.level.block.SlabBlock.WATERLOGGED)) {
+ throw new IllegalStateException("Cannot safely normalize a waterlogged double slab: " + target + ". "
+ + "Restore the backup and remove that invalid combination before upgrading.");
+ }
+ if (state.is(Blocks.BIRCH_SLAB)) {
+ state = Blocks.BIRCH_PLANKS.defaultBlockState();
+ } else if (state.is(Blocks.CHERRY_SLAB)) {
+ state = Blocks.CHERRY_PLANKS.defaultBlockState();
+ } else if (state.is(Blocks.MUD_BRICK_SLAB)) {
+ state = Blocks.MUD_BRICKS.defaultBlockState();
+ } else {
+ throw new IllegalStateException("No full-block replacement for double slab " + target);
+ }
+ }
+
+ CompoundTag result = NbtUtils.writeBlockState(state);
+
+ String nameKey = saved.contains("Name") ? "Name" : "id";
+ if (saved.size() == 1 && saved.contains("")) {
+ saved.remove("");
+ }
+ // Keep the input schema so vanilla's next data fix sees the format it expects.
+ saved.putString(nameKey, result.getString("id").orElseThrow());
+ if (result.get("properties") instanceof CompoundTag resultProperties) {
+ saved.put(propertiesKey, resultProperties);
+ } else {
+ saved.remove(propertiesKey);
+ }
+ }
+
+ private static > BlockState withProperty(BlockState state, Property property,
+ String value) {
+ return state.setValue(property, property.getValue(value).orElseThrow(
+ () -> new IllegalStateException("Invalid saved " + property.getName() + "=" + value)));
+ }
+
+ private static void depth(int depth) {
+ if (depth > 64) {
+ throw new IllegalStateException("Saved named BOP content exceeds 64 nesting levels");
+ }
+ }
+
+ // Raw tags are converted before deserialization. Ignoring these old registry entries avoids aliasing
+ // two identities onto an existing object, which would make mixed old/current saves ambiguous.
+
+ public static void missingBlocks(MissingMappingsEvent event) {
+ if (!event.getKey().equals(ForgeRegistries.Keys.BLOCKS)) return;
+ event.getAllMappings(ForgeRegistries.Keys.BLOCKS).stream().filter(mapping -> BLOCKS.containsKey(mapping.getKey().toString()))
+ .forEach(MissingMappingsEvent.Mapping::ignore);
+ }
+
+ public static void missingItems(MissingMappingsEvent event) {
+ if (!event.getKey().equals(ForgeRegistries.Keys.ITEMS)) return;
+ event.getAllMappings(ForgeRegistries.Keys.ITEMS).stream().filter(mapping -> ITEMS.containsKey(mapping.getKey().toString()))
+ .forEach(MissingMappingsEvent.Mapping::ignore);
+ }
+
+ public static void missingEntities(MissingMappingsEvent event) {
+ for (var mapping : event.getAllMappings(ForgeRegistries.Keys.ENTITY_TYPES)) {
+ if (OLD_BOATS.contains(mapping.getKey().toString())) {
+ mapping.ignore();
+ }
+ }
+ }
+
+ public static boolean ownsRetiredBiome(Identifier id) {
+ return BIOMES.containsKey(id.toString());
+ }
+
+ public static void missingBiomes(MissingMappingsEvent event) {
+ if (!event.getKey().equals(net.minecraft.core.registries.Registries.BIOME)) return;
+ event.getAllMappings(net.minecraft.core.registries.Registries.BIOME).stream().filter(mapping -> ownsRetiredBiome(mapping.getKey()))
+ .forEach(MissingMappingsEvent.Mapping::ignore);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/RetiredDecorations.java b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/RetiredDecorations.java
index 9d63c9af..95179ca4 100644
--- a/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/RetiredDecorations.java
+++ b/src/main/java/zone/moddev/mc/skysbuildingpiecesbop/RetiredDecorations.java
@@ -1,34 +1,43 @@
package zone.moddev.mc.skysbuildingpiecesbop;
-import java.util.Random;
-import net.minecraft.block.Block;
-import net.minecraft.block.SoundType;
-import net.minecraft.block.material.Material;
-import net.minecraft.block.properties.PropertyEnum;
-import net.minecraft.block.state.BlockStateContainer;
-import net.minecraft.block.state.BlockFaceShape;
-import net.minecraft.block.state.IBlockState;
-import net.minecraft.creativetab.CreativeTabs;
-import net.minecraft.item.Item;
-import net.minecraft.item.ItemBlock;
-import net.minecraft.util.BlockRenderLayer;
-import net.minecraft.util.EnumFacing;
-import net.minecraft.util.IStringSerializable;
-import net.minecraft.util.math.AxisAlignedBB;
-import net.minecraft.util.math.BlockPos;
-import net.minecraft.world.IBlockAccess;
-import net.minecraftforge.event.RegistryEvent;
-import net.minecraftforge.fml.common.Mod;
-import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+import java.util.Collections;
+import java.util.List;
+import net.minecraft.world.level.block.state.BlockBehaviour;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.SoundType;
+import net.minecraft.world.level.block.VineBlock;
+import net.minecraft.world.level.material.MapColor;
+import net.minecraft.world.item.BlockItem;
+import net.minecraft.world.item.context.BlockPlaceContext;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.CreativeModeTab;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.block.state.properties.EnumProperty;
+import net.minecraft.world.level.block.state.StateDefinition;
+import net.minecraft.core.Direction;
+import net.minecraft.util.StringRepresentable;
+import net.minecraft.resources.Identifier;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.phys.shapes.BooleanOp;
+import net.minecraft.world.phys.shapes.CollisionContext;
+import net.minecraft.world.phys.shapes.VoxelShape;
+import net.minecraft.world.phys.shapes.Shapes;
+import net.minecraft.world.level.BlockGetter;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.storage.loot.LootParams;
+import net.minecraftforge.registries.RegisterEvent;
+import net.minecraftforge.event.BuildCreativeModeTabContentsEvent;
+import net.minecraft.world.item.CreativeModeTabs;
+import net.minecraftforge.registries.ForgeRegistries;
import zone.moddev.mc.skysbuildingpieces.legacy.HistoricalFullBlocks;
-/** Replacements for removed decorations; these blocks never generate terrain. */
-@Mod.EventBusSubscriber(
- modid = SkysBuildingPiecesBop.ID)
+/** Restored decorations keep their original purpose and never generate terrain. */
public final class RetiredDecorations {
public static final String MODULE = "retired_bop_decorations";
- public static final Formation STONE_FORMATIONS = new Formation();
- public static final Block JELLED_POISON = new JelledPoison();
+ public static Formation STONE_FORMATIONS;
+ public static Block JELLED_POISON;
private RetiredDecorations() {
}
@@ -36,156 +45,161 @@ private RetiredDecorations() {
public static void registerRecovery() {
HistoricalFullBlocks.registerStates("biomesoplenty:stone_formations",
SkysBuildingPiecesBop.ID + ":legacy_bop_stone_formations", MODULE,
- meta -> meta == 0 ? STONE_FORMATIONS.getDefaultState() : null);
+ metadata -> metadata == 0 ? STONE_FORMATIONS.defaultBlockState() : null);
HistoricalFullBlocks.registerStates("biomesoplenty:jelled_poison",
SkysBuildingPiecesBop.ID + ":legacy_bop_jelled_poison", MODULE,
- meta -> meta == 0 ? JELLED_POISON.getDefaultState() : null);
+ metadata -> metadata == 0 ? JELLED_POISON.defaultBlockState() : null);
HistoricalFullBlocks.registerStates("biomesoplenty:tree_moss",
- SkysBuildingPiecesBop.ID + ":legacy_bop_tree_moss", MODULE, meta -> {
- Block vine = Block.getBlockFromName("biomesoplenty:willow_vine");
+ SkysBuildingPiecesBop.ID + ":legacy_bop_tree_moss", MODULE, RetiredDecorations::vine);
+ }
+
+ private static BlockState vine(int metadata) {
+ Block block = ForgeRegistries.BLOCKS.getValue(Identifier.fromNamespaceAndPath("biomesoplenty", "willow_vine"));
+
+ if (block == null || block == net.minecraft.world.level.block.Blocks.AIR || metadata < 0 || metadata > 15) {
+ return null;
+ }
- return vine == null ? null : vine.getStateFromMeta(meta);
- });
+ return block.defaultBlockState().setValue(VineBlock.SOUTH, (metadata & 1) != 0)
+ .setValue(VineBlock.WEST, (metadata & 2) != 0).setValue(VineBlock.NORTH, (metadata & 4) != 0)
+ .setValue(VineBlock.EAST, (metadata & 8) != 0);
}
- @SubscribeEvent
- public static void blocks(RegistryEvent.Register event) {
- event.getRegistry().registerAll(STONE_FORMATIONS, JELLED_POISON);
+ public static void blocks(RegisterEvent event) {
+ if (!event.getRegistryKey().equals(ForgeRegistries.Keys.BLOCKS)) return;
+ STONE_FORMATIONS = new Formation();
+ JELLED_POISON = new JelledPoison();
+ ForgeRegistries.BLOCKS.register(Identifier.fromNamespaceAndPath(SkysBuildingPiecesBop.ID, "stone_formations"), STONE_FORMATIONS);
+ ForgeRegistries.BLOCKS.register(Identifier.fromNamespaceAndPath(SkysBuildingPiecesBop.ID, "jelled_poison"), JELLED_POISON);
}
- @SubscribeEvent
- public static void items(RegistryEvent.Register
- event) {
+ public static void items(RegisterEvent event) {
+ if (!event.getRegistryKey().equals(ForgeRegistries.Keys.ITEMS)) return;
for (Block block : new Block[] { STONE_FORMATIONS, JELLED_POISON }) {
- event.getRegistry().register(new ItemBlock(block).setRegistryName(block.getRegistryName()));
+ Identifier id = ForgeRegistries.BLOCKS.getKey(block);
+ ForgeRegistries.ITEMS.register(id, new BlockItem(block, SkysBuildingPiecesBop.itemProperties(id)));
+ }
+ }
+
+ public static void creativeContents(BuildCreativeModeTabContentsEvent event) {
+ if (CreativeModeTabs.NATURAL_BLOCKS.equals(event.getTabKey())) {
+ event.accept(STONE_FORMATIONS);
+ event.accept(JELLED_POISON);
}
}
- public enum Form implements IStringSerializable {
+ public enum Form implements StringRepresentable {
FLOOR, CEILING, SINGLE, CONNECTOR, FLOOR_BASE, CEILING_BASE, FLOOR_TIP, CEILING_TIP;
@Override
- public String getName() {
+ public String getSerializedName() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
public static final class Formation extends Block {
- public static final PropertyEnum