diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 937c56bcb..ae2c9cedb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,9 +71,9 @@ jobs: --no-daemon --stacktrace first="$RUNNER_TEMP/basemetals-first" mkdir -p "$first" - cp build/libs/BaseMetals-3.1.1.121111.jar "$first/" - cp build/libs/BaseMetals-3.1.1.121111-sources.jar "$first/" - cp build/libs/BaseMetals-3.1.1.121111-javadoc.jar "$first/" + cp build/libs/BaseMetals-3.1.2.121111.jar "$first/" + cp build/libs/BaseMetals-3.1.2.121111-sources.jar "$first/" + cp build/libs/BaseMetals-3.1.2.121111-javadoc.jar "$first/" cp build/release/SHA256SUMS "$first/" - name: Rebuild and prove byte-identical archives @@ -81,9 +81,9 @@ jobs: ./gradlew clean assemble verifyReleaseArtifacts writeReleaseChecksums \ -PorespawnVerificationRepository="${{ steps.orespawn.outputs.repository }}" \ --no-daemon --stacktrace - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.121111.jar" build/libs/BaseMetals-3.1.1.121111.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.121111-sources.jar" build/libs/BaseMetals-3.1.1.121111-sources.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.121111-javadoc.jar" build/libs/BaseMetals-3.1.1.121111-javadoc.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.2.121111.jar" build/libs/BaseMetals-3.1.2.121111.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.2.121111-sources.jar" build/libs/BaseMetals-3.1.2.121111-sources.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.2.121111-javadoc.jar" build/libs/BaseMetals-3.1.2.121111-javadoc.jar cmp "$RUNNER_TEMP/basemetals-first/SHA256SUMS" build/release/SHA256SUMS - name: Run all required GameTests @@ -151,9 +151,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/BaseMetals-3.1.1.121111.jar - build/libs/BaseMetals-3.1.1.121111-sources.jar - build/libs/BaseMetals-3.1.1.121111-javadoc.jar + build/libs/BaseMetals-3.1.2.121111.jar + build/libs/BaseMetals-3.1.2.121111-sources.jar + build/libs/BaseMetals-3.1.2.121111-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index dd4450ea3..abae75e4e 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,10 @@ +Base Metals 3.1.2.121111 + +- Keep expansion machine directions, inventories and item wear before Minecraft converts old worlds. +- Carry the shared migration extension hook forward to Minecraft 1.21.11. +- Let companion chunk-migration callbacks finish before returning, so staged entities are not lost. +- Keep existing block and item identities, recipes and material balance unchanged. + Base Metals 3.1.1.121111 - Port the released 1.21.1 version to Minecraft 1.21.11 and Forge 61, keeping Java 21. diff --git a/README.md b/README.md index b01a4f3a9..cd022734f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ # Base Metals +The 3.1.2.121111 patch preserves expansion machine data before Minecraft +converts an older world. It also lets companion migration callbacks finish, +so restored entities are not lost. Machine, upgrade, restart and endurance +checks have passed with the matching basePower, baseSteam and baseElectric +builds. Keep a backup and test an upgrade on a copy of your world first. + Base Metals adds historically common metals, useful alloys, and fantasy metals to Minecraft. Depending on the material, these are available as ores, storage and decorative blocks, molten fluids, crafting components, tools, armour, @@ -12,7 +18,7 @@ shields, bows, crossbows, arrows, and bolts. The mod also includes Crack Hammers, scythes, metal anvils, the human detector, villager trades, advancements, and structure loot. -This is the Minecraft 1.21.11 Forge version, `3.1.1.121111`. It keeps the +This is the Minecraft 1.21.11 Forge version, `3.1.2.121111`. It keeps the fixes and features from the released 1.21.1 version, including raw metals, ore texture overlays and High/Low Fantasy modes. Existing Base Metals items and blocks keep their registry IDs. OreSpawn handles ore generation; MMDLib and @@ -28,7 +34,7 @@ Additional Loot Tables are no longer needed. The port has also been checked against Forge `61.2.4`. This version uses OreSpawn `4.1.0.121111`. Put -`BaseMetals-3.1.1.121111.jar` and a compatible OreSpawn JAR in the `mods` +`BaseMetals-3.1.2.121111.jar` and a compatible OreSpawn JAR in the `mods` directory. MMDLib and Additional Loot Tables must not be carried forward from an older installation. Mineralogy is optional. @@ -203,7 +209,7 @@ early splash window. Minecraft still opens its game window and runs the full world check. Ordinary client launches keep the normal splash screen. Release artifacts use Maven coordinate -`zone.moddev.mc.basemetals:BaseMetals:3.1.1.121111`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.2.121111`. Report defects and compatibility problems through the [Base Metals issue tracker](https://github.com/MinecraftModDevelopmentMods/BaseMetals/issues). diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index bd944f13c..de5279879 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -135,3 +135,12 @@ over an older backup. Chunk reading uses Minecraft's supported region formats. Empty region files are left alone, and backups include external chunk files when their region needs conversion. +# Companion Migration Hooks + +The local 3.1.2.121111 patch exposes `Legacy112MigrationExtension` for mods +whose machine state must be preserved before Base Metals runs Minecraft's +legacy chunk and player conversion. Register during common setup. The source +payload is prepared before flattening, and extensions can restore their +owned metadata states without replacing vanilla data fixes. The original +world backup and migration markers remain in force. + diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 04a6d99b7..0a61144ff 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -1,6 +1,6 @@ # Supported versions -Base Metals `3.1.1.121111` targets Minecraft 1.21.11, Forge 61.1.0 and Java 21. +Base Metals `3.1.2.121111` targets Minecraft 1.21.11, Forge 61.1.0 and Java 21. Packaged client, server and save-upgrade checks also pass on Forge 61.2.4. OreSpawn `4.1.0.121111` is the exact build and test dependency. Clients and servers need OreSpawn `[4.1.0.121111,5.0.0)`. @@ -11,7 +11,7 @@ Its SHA-256 is `DE39929057288CEA110196C9E9D708E129B80BC360A2E53976337FFA6A84A67B`. Release artifacts use Maven coordinate -`zone.moddev.mc.basemetals:BaseMetals:3.1.1.121111`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.2.121111`. The provider remains schema 3, revision 1. OreSpawn 4.1 is needed for the configuration-screen API; the provider's eleven rule IDs and 1.18 distributions diff --git a/gradle.properties b/gradle.properties index db7f0b26c..e8316718d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -67,7 +67,7 @@ mod_name=Base Metals # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-2.1 # The mod version. See https://semver.org/ -mod_version=3.1.1.121111 +mod_version=3.1.2.121111 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle index e7442b776..6e0ed2838 100644 --- a/gradle/ide/eclipse.gradle +++ b/gradle/ide/eclipse.gradle @@ -317,7 +317,7 @@ def verifyEclipseProductionClasspath = tasks.register('verifyEclipseProductionCl } } String metadata = new File(projectDir, 'bin/main/META-INF/mods.toml').getText('UTF-8') - if (metadata.contains('${') || !metadata.contains('version="3.1.1.121111"')) { + if (metadata.contains('${') || !metadata.contains('version="3.1.2.121111"')) { throw new GradleException('Eclipse output contains unresolved or invalid Base Metals version metadata') } } diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle index 83ddede2b..aad491881 100644 --- a/gradle/release/artifacts.gradle +++ b/gradle/release/artifacts.gradle @@ -202,7 +202,7 @@ tasks.register('verifyReleaseConfiguration') { dependsOn tasks.named('verifyMavenizerCompatibilityFixture') dependsOn tasks.named('verifyJava21Toolchain') doLast { - if (configuredReleaseIdentity.modVersion != '3.1.1.121111' + if (configuredReleaseIdentity.modVersion != '3.1.2.121111' || configuredReleaseIdentity.modGroup != expectedMavenGroup || configuredReleaseIdentity.minecraftVersion != '1.21.11' || configuredReleaseIdentity.forgeVersion != '61.1.0' @@ -326,7 +326,7 @@ tasks.register('verifyReleaseArtifacts') { } String modsToml = zip.getInputStream(zip.getEntry('META-INF/mods.toml')).getText('UTF-8') if (modsToml.contains('${') - || !modsToml.contains('version="3.1.1.121111"') + || !modsToml.contains('version="3.1.2.121111"') || !modsToml.contains('versionRange="[4.1.0.121111,5.0.0)"')) { throw new GradleException('Release mods.toml contains unresolved or incompatible metadata') } diff --git a/gradle/verification/packaged-runtime.gradle b/gradle/verification/packaged-runtime.gradle index ad9aa2aa4..30ee435d8 100644 --- a/gradle/verification/packaged-runtime.gradle +++ b/gradle/verification/packaged-runtime.gradle @@ -174,7 +174,7 @@ def packagedServerStartupSmoke = tasks.register('packagedServerStartupSmoke', Ex if (!marker.isFile()) throw new GradleException('Packaged dedicated-server completion marker is missing') Properties values = new Properties() marker.withInputStream { values.load(it) } - if (values.getProperty('basemetals') != '3.1.1.121111' + if (values.getProperty('basemetals') != '3.1.2.121111' || values.getProperty('orespawn') != '4.1.0.121111' || values.getProperty('forge') != '61.1.0') { throw new GradleException("Packaged dedicated-server versions are wrong: ${values}") diff --git a/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java b/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java new file mode 100644 index 000000000..7e26d4ff4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.basemetals.api.migration; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.state.BlockState; +import zone.moddev.mc.basemetals.migration.Legacy112WorldMigrator; + +/** Preserves companion data before Base Metals converts a pre-flattening world. */ +public interface Legacy112MigrationExtension { + enum Payload { CHUNK, PLAYER } + + /** Mutates owned legacy data before vanilla fixes and the block-entity snapshot. */ + default boolean prepareData(CompoundTag tag, int dataVersion, Payload payload) { return false; } + + /** Runs immediately before the intermediate chunk data fix. */ + default void beforeChunkFlattening(CompoundTag chunk) { } + + /** + * Restores owned data after vanilla reaches the 1.20.1 item schema, before + * native item componentization. The prepared source is read-only; the + * mutable result still stores historical Count/tag item payloads. + */ + default void afterPlayerFlattening(CompoundTag preparedSource, CompoundTag result) { } + + /** Returns a native owned state, or null for an unowned registry ID. */ + default BlockState decodeBlockState(Identifier legacyId, int metadata) { return null; } + + /** Register during common setup, before any world is opened. */ + static void register(Identifier id, Legacy112MigrationExtension extension) { + Legacy112WorldMigrator.registerExtension(id, extension); + } +} diff --git a/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java b/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java index 09f26f0c6..97ebd228b 100644 --- a/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java +++ b/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java @@ -22,11 +22,15 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentSkipListMap; import java.util.zip.DeflaterOutputStream; import zone.moddev.mc.basemetals.BaseMetals; import zone.moddev.mc.basemetals.MissingMappings; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension.Payload; import zone.moddev.mc.basemetals.content.BaseMetalAnvilBlock; import zone.moddev.mc.basemetals.content.CompatibilityDoubleSlabBlock; import zone.moddev.mc.basemetals.content.ModContent; @@ -77,13 +81,17 @@ public final class Legacy112WorldMigrator { private static final int LAST_SUPPORTED_LEGACY_DATA_VERSION = 1_343; private static final int PALETTED_PRE_PROTOCHUNK_DATA_VERSION = 1_456; + private static final int NAMESPACED_BLOCK_ENTITY_DATA_VERSION = 704; + private static final int PRE_COMPONENT_PLAYER_DATA_VERSION = 3_465; private static final int REGION_HEADER_BYTES = 8_192; private static final int SECTOR_BYTES = 4_096; private static final String MARKER = "BASEMETALS_1_12_BLOCK_MIGRATION_COMPLETE.txt"; private static final String BACKUP_DIRECTORY = "basemetals-1.12-region-backup"; + private static final Map EXTENSIONS = new ConcurrentSkipListMap<>(); private static Path preparedWorld; private static CompoundTag preparedPlayer; private static int preparedVersion; + private static CompoundTag preparedIntegratedPlayer; private Legacy112WorldMigrator() {} @@ -91,6 +99,7 @@ static void beginWorldLoad() { preparedWorld = null; preparedPlayer = null; preparedVersion = 0; + preparedIntegratedPlayer = null; } /** Keep the original player before Forge and vanilla read the old save. */ @@ -125,7 +134,17 @@ private static boolean hasLegacySource(Path root) throws IOException { .anyMatch(id -> id.startsWith(BaseMetals.MOD_ID + ":") || id.startsWith("mmdlib:")); } + public static void registerExtension(Identifier id, Legacy112MigrationExtension extension) { + if (EXTENSIONS.putIfAbsent(Objects.requireNonNull(id), Objects.requireNonNull(extension)) != null) { + throw new IllegalArgumentException("Duplicate legacy world migration extension: " + id); + } + } + public static void migrateIfNeeded(Path worldRoot) throws IOException { + migrateIfNeeded(worldRoot, List.copyOf(EXTENSIONS.values())); + } + + static void migrateIfNeeded(Path worldRoot, List extensions) throws IOException { Path root = worldRoot.toAbsolutePath().normalize(); if (Files.isRegularFile(root.resolve(MARKER))) return; @@ -145,7 +164,7 @@ public static void migrateIfNeeded(Path worldRoot) throws IOException { int convertedItemAliases = 0; int convertedDamageValues = 0; for (Path region : regions) { - RegionConversion result = convertRegion(root, region, oldBlockIds); + RegionConversion result = convertRegion(root, region, oldBlockIds, extensions); if (result.convertedChunks() > 0) { convertedRegions++; convertedChunks += result.convertedChunks(); @@ -156,14 +175,14 @@ public static void migrateIfNeeded(Path worldRoot) throws IOException { convertedDamageValues += result.convertedDamageValues(); } } - ItemMigration looseItems = restoreRewrittenSingleplayerPlayer(root) - .plus(migrateLooseInventories(root)); + ItemMigration looseItems = restoreRewrittenSingleplayerPlayer(root, extensions) + .plus(migrateLooseInventories(root, extensions)); convertedBuckets += looseItems.convertedBuckets(); convertedItemAliases += looseItems.convertedItemAliases(); convertedDamageValues += looseItems.convertedDamageValues(); if (convertedChunks == 0 && convertedBuckets == 0 - && convertedItemAliases == 0 && convertedDamageValues == 0) { + && convertedItemAliases == 0 && convertedDamageValues == 0 && !looseItems.extensionChanged()) { Files.writeString(root.resolve(MARKER), "No legacy Forge numeric chunks or filled buckets required Base Metals migration." + System.lineSeparator(), @@ -173,11 +192,12 @@ public static void migrateIfNeeded(Path worldRoot) throws IOException { String result = "Converted " + convertedChunks + " legacy chunks in " + convertedRegions + " region files through Mojang's legacy flattening fixes. Backups: " + BACKUP_DIRECTORY - + ". Third-party states preserved by registry name at their default state: " + + ". Third-party states restored: " + externalStates + ". Legacy block entities preserved: " + preservedBlockEntities + ". Legacy filled buckets converted: " + convertedBuckets + ". Legacy item aliases forwarded: " + convertedItemAliases - + ". Mod-item damage values preserved: " + convertedDamageValues + "."; + + ". Mod-item damage values preserved: " + convertedDamageValues + "." + + (looseItems.extensionChanged() ? " Companion player data migrated." : ""); Files.writeString(root.resolve(MARKER), result + System.lineSeparator(), StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); BaseMetals.LOGGER.info(result); @@ -190,14 +210,22 @@ public static void migrateIfNeeded(Path worldRoot) throws IOException { * single-player session receives the same item migration as playerdata. */ public static void refreshLoadedSingleplayerPlayer(MinecraftServer server, Path worldRoot) throws IOException { - CompoundTag loaded = server.getWorldData().getLoadedPlayerTag(); + refreshLoadedSingleplayerPlayer(server.getWorldData().getLoadedPlayerTag(), worldRoot); + } + + static void refreshLoadedSingleplayerPlayer(CompoundTag loaded, Path worldRoot) throws IOException { if (loaded == null) return; - CompoundTag level = LegacyNbt.readCompressed(worldRoot.resolve("level.dat").toFile()); - CompoundTag data = level.getCompoundOrEmpty("Data"); - if (!zone.moddev.mc.basemetals.migration.NbtCompat.containsTag(data, "Player", Tag.TAG_COMPOUND)) return; - int version = data.getIntOr("DataVersion", 0); - CompoundTag corrected = DataFixTypes.PLAYER.updateToCurrentVersion(DataFixers.getDataFixer(), - data.getCompoundOrEmpty("Player"), version); + CompoundTag corrected; + if (preparedIntegratedPlayer != null && worldRoot.toAbsolutePath().normalize().equals(preparedWorld)) { + corrected = preparedIntegratedPlayer; + preparedIntegratedPlayer = null; + } else { + CompoundTag level = LegacyNbt.readCompressed(worldRoot.resolve("level.dat").toFile()); + CompoundTag data = level.getCompoundOrEmpty("Data"); + if (!NbtCompat.containsTag(data, "Player", Tag.TAG_COMPOUND)) return; + corrected = DataFixTypes.PLAYER.updateToCurrentVersion(DataFixers.getDataFixer(), + data.getCompoundOrEmpty("Player"), data.getIntOr("DataVersion", 0)); + } for (String key : List.copyOf(loaded.keySet())) loaded.remove(key); loaded.merge(corrected); } @@ -272,7 +300,8 @@ private static void ensureBackupCapacity(Path root, List regions) throws I } } - private static RegionConversion convertRegion(Path root, Path region, Map ids) throws IOException { + private static RegionConversion convertRegion(Path root, Path region, Map ids, + List extensions) throws IOException { byte[] source = Files.readAllBytes(region); if (source.length == 0) return new RegionConversion(0, 0, 0, 0, 0, 0); if (source.length < REGION_HEADER_BYTES) throw new IOException("Invalid region header: " + region); @@ -316,7 +345,7 @@ private static RegionConversion convertRegion(Path root, Path region, Map ids) throws IOException { + private static ChunkConversion convertChunk(CompoundTag chunk, Map ids, + List extensions) throws IOException { int sourceVersion = chunk.getIntOr("DataVersion", 0); if (sourceVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION) { return new ChunkConversion(false, 0, 0, 0, 0, 0, chunk); } ItemMigration itemMigration = convertLegacyItems(chunk); + boolean extensionChanged = prepareExtensions(extensions, chunk, sourceVersion, Payload.CHUNK); CompoundTag level = chunk.getCompoundOrEmpty("Level"); ListTag sections = level.getListOrEmpty("Sections"); List savedBlocks = new ArrayList<>(); @@ -374,16 +405,18 @@ private static ChunkConversion convertChunk(CompoundTag chunk, Map> savedBlockEntities = snapshotLegacyBlockEntities(level, savedBlocks); - if (savedBlocks.isEmpty() && itemMigration.isEmpty()) { + if (savedBlocks.isEmpty() && itemMigration.isEmpty() && !extensionChanged) { return new ChunkConversion(false, 0, 0, 0, 0, 0, chunk); } + for (Legacy112MigrationExtension extension : extensions) extension.beforeChunkFlattening(chunk); + Map> savedBlockEntities = + snapshotLegacyBlockEntities(chunk.getCompoundOrEmpty("Level"), savedBlocks); CompoundTag fixed = DataFixTypes.CHUNK.update(DataFixers.getDataFixer(), chunk, sourceVersion, PALETTED_PRE_PROTOCHUNK_DATA_VERSION); fixed.putInt("DataVersion", PALETTED_PRE_PROTOCHUNK_DATA_VERSION); if (!savedBlocks.isEmpty()) { - restoreSavedBlocks(fixed, savedBlocks); + restoreSavedBlocks(fixed, savedBlocks, extensions); restoreLegacyBlockEntities(fixed, savedBlocks, savedBlockEntities); } int preservedBlockEntities = savedBlockEntities.values().stream().mapToInt(List::size).sum(); @@ -392,14 +425,29 @@ private static ChunkConversion convertChunk(CompoundTag chunk, Map ids) throws IOException { - ChunkConversion conversion = convertChunk(chunk, ids); + return convertChunkForTest(chunk, ids, List.of()); + } + + static CompoundTag convertChunkForTest(CompoundTag chunk, Map ids, + List extensions) throws IOException { + ChunkConversion conversion = convertChunk(chunk, ids, extensions); if (!conversion.converted()) throw new IOException("Validation chunk did not contain a third-party block"); return conversion.chunk(); } - private static ItemMigration migrateLooseInventories(Path root) throws IOException { + private static boolean prepareExtensions(List extensions, CompoundTag tag, + int dataVersion, Payload payload) { + if (dataVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION) return false; + boolean changed = false; + for (Legacy112MigrationExtension extension : extensions) changed |= extension.prepareData(tag, dataVersion, payload); + return changed; + } + + private static ItemMigration migrateLooseInventories(Path root, List extensions) + throws IOException { List files = new ArrayList<>(); - files.add(root.resolve("level.dat")); + Path levelDat = root.resolve("level.dat"); + files.add(levelDat); Path playerData = root.resolve("playerdata"); if (Files.isDirectory(playerData)) { try (var paths = Files.list(playerData)) { @@ -415,18 +463,37 @@ private static ItemMigration migrateLooseInventories(Path root) throws IOExcepti if (!Files.isRegularFile(file)) continue; CompoundTag data = LegacyNbt.readCompressed(file.toFile()); ItemMigration changed = convertLegacyItems(data); - if (changed.isEmpty()) continue; + boolean extensionChanged = false; + if (file.equals(levelDat)) { + CompoundTag world = data.getCompoundOrEmpty("Data"); + if (!extensions.isEmpty() && isLegacyLevel(data) && NbtCompat.containsTag(world, "Player", Tag.TAG_COMPOUND)) { + CompoundTag player = world.getCompoundOrEmpty("Player"); + int version = world.getIntOr("DataVersion", 0); + extensionChanged = prepareExtensions(extensions, player, version, Payload.PLAYER); + if (root.equals(preparedWorld)) { + // Integrated startup can still have legacy level.dat on disk. Keep its + // version and layout intact; refresh only the already-loaded player. + PlayerConversion fixed = flattenPreparedPlayer(player, version, extensions); + preparedIntegratedPlayer = fixed.player(); + converted = converted.withExtensionChange(fixed.extensionChanged()); + } + } + } else { + extensionChanged = prepareExtensions(extensions, data, data.getIntOr("DataVersion", 0), Payload.PLAYER); + } + if (changed.isEmpty() && !extensionChanged) continue; Path backup = root.resolve(BACKUP_DIRECTORY).resolve(root.relativize(file)); Files.createDirectories(backup.getParent()); if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES); writeCompressedAtomically(file, data); - converted = converted.plus(changed); + converted = converted.plus(changed.withExtensionChange(extensionChanged)); } return converted; } /** Restore only the player captured from this load's original legacy save. */ - private static ItemMigration restoreRewrittenSingleplayerPlayer(Path root) throws IOException { + private static ItemMigration restoreRewrittenSingleplayerPlayer(Path root, + List extensions) throws IOException { Path currentFile = root.resolve("level.dat"); if (!root.equals(preparedWorld) || preparedPlayer == null || !Files.isRegularFile(currentFile)) { return ItemMigration.NONE; @@ -441,15 +508,40 @@ private static ItemMigration restoreRewrittenSingleplayerPlayer(Path root) throw CompoundTag player = preparedPlayer.copy(); ItemMigration migration = convertLegacyItems(player); - CompoundTag fixedPlayer = DataFixTypes.PLAYER.updateToCurrentVersion(DataFixers.getDataFixer(), - player, preparedVersion); - currentData.put("Player", fixedPlayer); + boolean extensionChanged = prepareExtensions(extensions, player, preparedVersion, Payload.PLAYER); + PlayerConversion fixed = flattenPreparedPlayer(player, preparedVersion, extensions); + currentData.put("Player", fixed.player()); Path backup = root.resolve(BACKUP_DIRECTORY).resolve(root.relativize(currentFile)); Files.createDirectories(backup.getParent()); if (!Files.exists(backup)) Files.copy(currentFile, backup, StandardCopyOption.COPY_ATTRIBUTES); writeCompressedAtomically(currentFile, current); - return migration; + return migration.withExtensionChange(extensionChanged || fixed.extensionChanged()); + } + + private static PlayerConversion flattenPreparedPlayer(CompoundTag player, int version, + List extensions) { + var fixer = DataFixers.getDataFixer(); + if (extensions.isEmpty()) { + return new PlayerConversion(DataFixTypes.PLAYER.updateToCurrentVersion(fixer, player, version), false); + } + + CompoundTag preparedSource = player.copy(); + if (version < NAMESPACED_BLOCK_ENTITY_DATA_VERSION) { + player = DataFixTypes.PLAYER.update(fixer, player, version, + NAMESPACED_BLOCK_ENTITY_DATA_VERSION); + version = NAMESPACED_BLOCK_ENTITY_DATA_VERSION; + } + + // Give extensions the flattened 1.20.1 item shape so they can restore names before componentization. + CompoundTag flattened = DataFixTypes.PLAYER.update(fixer, player, version, PRE_COMPONENT_PLAYER_DATA_VERSION); + CompoundTag beforeCallbacks = flattened.copy(); + for (Legacy112MigrationExtension extension : extensions) { + extension.afterPlayerFlattening(preparedSource, flattened); + } + boolean changed = !beforeCallbacks.equals(flattened); + CompoundTag current = DataFixTypes.PLAYER.updateToCurrentVersion(fixer, flattened, PRE_COMPONENT_PLAYER_DATA_VERSION); + return new PlayerConversion(current, changed); } private static ItemMigration convertLegacyItems(Tag value) { @@ -560,7 +652,8 @@ private static SectionSnapshot snapshotSection(CompoundTag section, Map savedBlocks) throws IOException { + private static void restoreSavedBlocks(CompoundTag chunk, List savedBlocks, + List extensions) throws IOException { Map targetSections = new HashMap<>(); ListTag sections = chunk.getCompoundOrEmpty("Level").getListOrEmpty("Sections"); for (Tag value : sections) { @@ -592,7 +685,7 @@ private static void restoreSavedBlocks(CompoundTag chunk, List save } for (LegacyBlock legacy : entry.getValue()) { - CompoundTag state = restoredState(legacy, byPosition); + CompoundTag state = restoredState(legacy, byPosition, extensions); Integer paletteId = paletteIds.get(state); if (paletteId == null) { paletteId = palette.size(); @@ -686,8 +779,8 @@ private static int bitsForPalette(int paletteSize) { return Math.max(4, 32 - Integer.numberOfLeadingZeros(Math.max(1, paletteSize - 1))); } - private static CompoundTag restoredState(LegacyBlock legacy, Map byPosition) - throws IOException { + private static CompoundTag restoredState(LegacyBlock legacy, Map byPosition, + List extensions) throws IOException { String normalizedName = normalizeLegacyRegistryName(legacy.name()); Identifier oldId = Identifier.tryParse(normalizedName); if (oldId == null) throw new IOException("Invalid legacy block registry name " + legacy.name()); @@ -714,6 +807,17 @@ private static CompoundTag restoredState(LegacyBlock legacy, Map blocks, int externalStates) {} diff --git a/src/main/java/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.java b/src/main/java/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.java index 353f6b355..69c4c4419 100644 --- a/src/main/java/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.java +++ b/src/main/java/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.java @@ -19,9 +19,10 @@ abstract class SimpleRegionStorageMixin { } @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;)Lnet/minecraft/nbt/CompoundTag;", - at = @At("RETURN"), cancellable = true) + at = @At("RETURN")) private void basemetals$finishChunk(CompoundTag chunk, int fallbackVersion, CompoundTag context, CallbackInfoReturnable callback) { - callback.setReturnValue(LegacyWorldDataHook.finalizeLegacyChunk(callback.getReturnValue())); + // The hook edits this compound in place; cancellation would skip companion hooks. + LegacyWorldDataHook.finalizeLegacyChunk(callback.getReturnValue()); } } diff --git a/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml b/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml index 931fea1c0..68d61a444 100644 --- a/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml +++ b/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml @@ -10,7 +10,7 @@ displayName="Base Metals packaged client smoke" [[dependencies.basemetals_client_smoke]] modId="basemetals" mandatory=true -versionRange="[3.1.1.121111]" +versionRange="[3.1.2.121111]" ordering="AFTER" side="CLIENT" diff --git a/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java b/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java index 8d13fffd9..f3ef8e6ae 100644 --- a/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java +++ b/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java @@ -40,7 +40,7 @@ public PackagedServerSmoke() { public void onServerStarted(ServerStartedEvent event) { String baseMetals = version("basemetals"); String oreSpawn = version("orespawn"); - if (!"3.1.1.121111".equals(baseMetals) || !"4.1.0.121111".equals(oreSpawn)) { + if (!"3.1.2.121111".equals(baseMetals) || !"4.1.0.121111".equals(oreSpawn)) { throw new IllegalStateException("PACKAGED_SERVER_SMOKE FAIL versions basemetals=" + baseMetals + " orespawn=" + oreSpawn); } diff --git a/src/packagedServerSmoke/packaged-resources/META-INF/mods.toml b/src/packagedServerSmoke/packaged-resources/META-INF/mods.toml index b03103032..5071a819f 100644 --- a/src/packagedServerSmoke/packaged-resources/META-INF/mods.toml +++ b/src/packagedServerSmoke/packaged-resources/META-INF/mods.toml @@ -10,7 +10,7 @@ displayName="Base Metals packaged server smoke" [[dependencies.basemetals_server_smoke]] modId="basemetals" mandatory=true -versionRange="[3.1.1.121111]" +versionRange="[3.1.2.121111]" ordering="AFTER" side="SERVER" diff --git a/src/test/java/zone/moddev/mc/basemetals/migration/Legacy112MigrationExtensionTest.java b/src/test/java/zone/moddev/mc/basemetals/migration/Legacy112MigrationExtensionTest.java new file mode 100644 index 000000000..77ed97db4 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basemetals/migration/Legacy112MigrationExtensionTest.java @@ -0,0 +1,456 @@ +package zone.moddev.mc.basemetals.migration; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.minecraft.SharedConstants; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.BarrelBlock; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.CleanupMode; +import org.junit.jupiter.api.io.TempDir; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension; + +class Legacy112MigrationExtensionTest { + private static final Map COMPANION_BLOCKS = Map.of( + 0, "minecraft:air", 200, "example:legacy_block"); + private static final String MARKER = "BASEMETALS_1_12_BLOCK_MIGRATION_COMPLETE.txt"; + + @BeforeAll + static void gameVersion() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + void migratesAThirdPartyItemInAVanillaOnlyChunkBeforeTheDataFixer() throws Exception { + CompoundTag chunk = chunk(54, 2); + CompoundTag chest = chunk.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities").getCompoundOrEmpty(0); + List calls = new ArrayList<>(); + Legacy112MigrationExtension extension = new Legacy112MigrationExtension() { + @Override public boolean prepareData(CompoundTag tag, int version, Payload payload) { + assertEquals(1343, version); + assertEquals(Payload.CHUNK, payload); + CompoundTag stack = chest.getListOrEmpty("Items").getCompoundOrEmpty(0); + prepareStack(stack); + calls.add("prepare"); + return true; + } + @Override public void beforeChunkFlattening(CompoundTag source) { + assertSame(chunk, source); + assertEquals(3, chest.getListOrEmpty("Items").getCompoundOrEmpty(0).getCompoundOrEmpty("tag").getIntOr("Damage", 0)); + calls.add("flatten"); + } + }; + CompoundTag fixed = Legacy112WorldMigrator.convertChunkForTest(chunk, + Map.of(0, "minecraft:air", 54, "minecraft:chest"), List.of(extension)); + CompoundTag stack = fixed.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities") + .getCompoundOrEmpty(0).getListOrEmpty("Items").getCompoundOrEmpty(0); + assertEquals(List.of("prepare", "flatten"), calls); + assertEquals("example:wrench", stack.getStringOr("id", "")); + assertEquals(1, stack.getByteOr("Count", (byte) 0)); + assertEquals(4, stack.getByteOr("Slot", (byte) 0)); + assertEquals(3, stack.getCompoundOrEmpty("tag").getIntOr("Damage", 0)); + + CompoundTag current = DataFixTypes.CHUNK.updateToCurrentVersion( + DataFixers.getDataFixer(), fixed, fixed.getIntOr("DataVersion", 0)); + assertComponentStack(current.getListOrEmpty("block_entities") + .getCompoundOrEmpty(0).getListOrEmpty("Items").getCompoundOrEmpty(0)); + } + + @Test + void unclaimedThirdPartyBlocksStillKeepTheirNameAndTheDecoderReceivesRawMetadata() throws Exception { + List metadata = new ArrayList<>(); + Legacy112MigrationExtension extension = new Legacy112MigrationExtension() { + @Override public BlockState decodeBlockState(Identifier id, int data) { + assertEquals(Identifier.fromNamespaceAndPath("example", "legacy_block"), id); + metadata.add(data); + return null; + } + }; + CompoundTag fixed = Legacy112WorldMigrator.convertChunkForTest(chunk(200, 13), + COMPANION_BLOCKS, List.of(extension)); + assertEquals(List.of(13), metadata); + assertEquals("example:legacy_block", Legacy112WorldMigrator.stateAtForTest(fixed, 0, 0).getStringOr("Name", "")); + } + + @Test + void savesTheDecodedBlockPropertiesAndThePreparedOpaqueBlockEntity() throws Exception { + CompoundTag chunk = chunk(200, 13); + CompoundTag entity = new CompoundTag(); + entity.putString("id", "example:machine"); + entity.putInt("x", 0); + entity.putInt("y", 0); + entity.putInt("z", 0); + chunk.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities").add(entity); + Legacy112MigrationExtension extension = new Legacy112MigrationExtension() { + @Override public void beforeChunkFlattening(CompoundTag source) { + source.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities") + .getCompoundOrEmpty(0).putInt("energy", 125); + } + + @Override public BlockState decodeBlockState(Identifier id, int data) { + assertEquals(13, data); + return Blocks.BARREL.defaultBlockState().setValue(BarrelBlock.FACING, Direction.EAST); + } + }; + CompoundTag fixed = Legacy112WorldMigrator.convertChunkForTest(chunk, COMPANION_BLOCKS, List.of(extension)); + CompoundTag state = Legacy112WorldMigrator.stateAtForTest(fixed, 0, 0); + assertEquals("minecraft:barrel", state.getStringOr("Name", "")); + assertEquals("east", state.getCompoundOrEmpty("Properties").getStringOr("facing", "")); + CompoundTag saved = fixed.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities").getCompoundOrEmpty(0); + assertEquals("example:machine", saved.getStringOr("id", "")); + assertEquals(125, saved.getIntOr("energy", 0)); + } + + @Test + void rejectsAirAndAmbiguousDecoders() { + Legacy112MigrationExtension air = new Legacy112MigrationExtension() { + @Override public BlockState decodeBlockState(Identifier id, int data) { + return Blocks.AIR.defaultBlockState(); + } + }; + Legacy112MigrationExtension stone = new Legacy112MigrationExtension() { + @Override public BlockState decodeBlockState(Identifier id, int data) { + return Blocks.STONE.defaultBlockState(); + } + }; + assertThrows(IOException.class, () -> Legacy112WorldMigrator.convertChunkForTest( + chunk(200, 13), COMPANION_BLOCKS, List.of(air))); + assertThrows(IOException.class, () -> Legacy112WorldMigrator.convertChunkForTest( + chunk(200, 13), COMPANION_BLOCKS, List.of(stone, stone))); + } + + @Test + void refreshesTheIntegratedPlayerWithoutWritingComponentsIntoALegacyLevel( + @TempDir(cleanup = CleanupMode.ON_SUCCESS) Path world) throws Exception { + verifyEmbeddedPlayer(world, 1343, false); + } + + @Test + void restoresTheCapturedPlayerAfterTheDedicatedServerRewritesALegacyLevel( + @TempDir(cleanup = CleanupMode.ON_SUCCESS) Path world) throws Exception { + verifyEmbeddedPlayer(world, 512, true); + } + + @Test + void restoresTheCapturedPlayerWithTheUnsplitNativeFixerWhenNoExtensionIsRegistered( + @TempDir(cleanup = CleanupMode.ON_SUCCESS) Path world) throws Exception { + Path levelDat = world.resolve("level.dat"); + CompoundTag original = player(); + addPlayerWitnesses(original); + CompoundTag legacy = level(512, original); + Legacy112WorldMigrator.beginWorldLoad(); + try { + Legacy112WorldMigrator.prepareWorld(levelDat, legacy); + LegacyNbt.writeCompressed(level(currentVersion(), new CompoundTag()), levelDat.toFile()); + CompoundTag expected = DataFixTypes.PLAYER.updateToCurrentVersion( + DataFixers.getDataFixer(), original.copy(), 512); + + Legacy112WorldMigrator.migrateIfNeeded(world, List.of()); + + CompoundTag restored = LegacyNbt.readCompressed(levelDat.toFile()) + .getCompoundOrEmpty("Data").getCompoundOrEmpty("Player"); + assertEquals(expected, restored); + assertEquals(7, stack(restored, "Inventory", 2).getIntOr("count", 0)); + assertEquals("minecraft:red_wool", stack(restored, "Inventory", 2).getStringOr("id", "")); + } finally { + Legacy112WorldMigrator.beginWorldLoad(); + } + } + + private static void verifyEmbeddedPlayer(Path world, int version, boolean rewritten) throws Exception { + Path levelDat = world.resolve("level.dat"); + CompoundTag original = player(); + addPlayerWitnesses(original); + CompoundTag legacy = level(version, original); + LegacyNbt.writeCompressed(legacy, levelDat.toFile()); + Legacy112WorldMigrator.beginWorldLoad(); + try { + Legacy112WorldMigrator.prepareWorld(levelDat, legacy); + CompoundTag onDisk = rewritten ? level(currentVersion(), new CompoundTag()) : legacy; + if (rewritten) LegacyNbt.writeCompressed(onDisk, levelDat.toFile()); + byte[] originalLevel = Files.readAllBytes(levelDat); + List calls = new ArrayList<>(); + Legacy112MigrationExtension extension = new Legacy112MigrationExtension() { + @Override public boolean prepareData(CompoundTag tag, int dataVersion, Payload payload) { + assertEquals(version, dataVersion); + assertEquals(Payload.PLAYER, payload); + prepareStack(inventoryStack(tag)); + calls.add("prepare"); + return true; + } + + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + assertEquals(3, inventoryStack(source).getCompoundOrEmpty("tag").getIntOr("Damage", 0)); + assertLegacyWitnesses(result); + assertEquals(3, inventoryStack(result).getCompoundOrEmpty("tag").getIntOr("Damage", 0)); + assertEquals("minecraft:wool", stack(source, "Inventory", 2).getStringOr("id", "")); + restoreBookName(source, result, "Inventory", 1, "example:precision", 3); + restoreBookName(source, result, "EnderItems", 0, "example:ward", 2); + result.putInt("companionEnergy", 125); + calls.add("after"); + } + }; + Legacy112MigrationExtension observer = new Legacy112MigrationExtension() { + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + assertEquals(List.of("prepare", "after"), calls); + assertLegacyWitnesses(result); + assertEquals(125, result.getIntOr("companionEnergy", 0)); + assertEquals("example:precision", storedEnchantment(stack(result, "Inventory", 1)).getStringOr("id", "")); + assertEquals("example:ward", storedEnchantment(stack(result, "EnderItems", 0)).getStringOr("id", "")); + calls.add("observe"); + } + }; + List extensions = List.of(extension, observer); + Legacy112WorldMigrator.migrateIfNeeded(world, extensions); + CompoundTag loaded = new CompoundTag(); + loaded.putBoolean("stale", true); + Legacy112WorldMigrator.refreshLoadedSingleplayerPlayer(loaded, world); + CompoundTag diskData = LegacyNbt.readCompressed(levelDat.toFile()).getCompoundOrEmpty("Data"); + assertEquals(List.of("prepare", "after", "observe"), calls); + assertFalse(loaded.contains("stale")); + assertComponentStack(inventoryStack(loaded)); + assertComponentBook(stack(loaded, "Inventory", 1), 9, "example:precision", 3); + assertComponentBook(stack(loaded, "EnderItems", 0), 5, "example:ward", 2); + CompoundTag wool = stack(loaded, "Inventory", 2); + assertEquals("minecraft:red_wool", wool.getStringOr("id", "")); + assertEquals(7, wool.getIntOr("count", 0)); + assertEquals(12, wool.getByteOr("Slot", (byte) 0)); + assertFalse(wool.contains("Count")); + assertEquals(125, loaded.getIntOr("companionEnergy", 0)); + assertEquals(rewritten ? currentVersion() : version, diskData.getIntOr("DataVersion", 0)); + if (rewritten) { + assertEquals(loaded, diskData.getCompoundOrEmpty("Player")); + } else { + CompoundTag stack = inventoryStack(diskData.getCompoundOrEmpty("Player")); + assertEquals(3, stack.getCompoundOrEmpty("tag").getIntOr("Damage", 0)); + assertFalse(stack.contains("components")); + assertFalse(diskData.getCompoundOrEmpty("Player").contains("companionEnergy")); + } + assertArrayEquals(originalLevel, Files.readAllBytes(world.resolve("basemetals-1.12-region-backup/level.dat"))); + assertTrue(Files.readString(world.resolve(MARKER)).contains("Companion player data migrated.")); + Legacy112WorldMigrator.migrateIfNeeded(world, extensions); + assertEquals(List.of("prepare", "after", "observe"), calls); + } finally { + Legacy112WorldMigrator.beginWorldLoad(); + } + } + + @Test + void onlyLegacyLoosePlayerDataReceivesThePrepareHook( + @TempDir(cleanup = CleanupMode.ON_SUCCESS) Path world) throws Exception { + LegacyNbt.writeCompressed(level(1343, null), world.resolve("level.dat").toFile()); + Path playerData = Files.createDirectory(world.resolve("playerdata")); + Path legacyFile = playerData.resolve("legacy.dat"); + Path modernFile = playerData.resolve("modern.dat"); + CompoundTag legacy = player(); + legacy.putInt("DataVersion", 1343); + LegacyNbt.writeCompressed(legacy, legacyFile.toFile()); + CompoundTag modern = new CompoundTag(); + modern.putInt("DataVersion", currentVersion()); + LegacyNbt.writeCompressed(modern, modernFile.toFile()); + byte[] originalLegacy = Files.readAllBytes(legacyFile); + byte[] originalModern = Files.readAllBytes(modernFile); + List versions = new ArrayList<>(); + Legacy112MigrationExtension extension = new Legacy112MigrationExtension() { + @Override public boolean prepareData(CompoundTag tag, int version, Payload payload) { + assertEquals(Payload.PLAYER, payload); + versions.add(version); + prepareStack(inventoryStack(tag)); + return true; + } + + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + fail("The embedded player hook must not run for detached playerdata"); + } + }; + Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension)); + assertEquals(List.of(1343), versions); + assertArrayEquals(originalModern, Files.readAllBytes(modernFile)); + assertArrayEquals(originalLegacy, + Files.readAllBytes(world.resolve("basemetals-1.12-region-backup/playerdata/legacy.dat"))); + CompoundTag prepared = LegacyNbt.readCompressed(legacyFile.toFile()); + assertEquals(1343, prepared.getIntOr("DataVersion", 0)); + assertComponentStack(inventoryStack(DataFixTypes.PLAYER.updateToCurrentVersion( + DataFixers.getDataFixer(), prepared, 1343))); + } + + private static int currentVersion() { + return SharedConstants.getCurrentVersion().dataVersion().version(); + } + + private static CompoundTag level(int version, CompoundTag player) { + CompoundTag root = new CompoundTag(); + CompoundTag data = new CompoundTag(); + data.putInt("DataVersion", version); + if (player != null) data.put("Player", player); + root.put("Data", data); + return root; + } + + private static CompoundTag player() { + CompoundTag player = new CompoundTag(); + ListTag items = new ListTag(); + items.add(legacyStack()); + player.put("Inventory", items); + return player; + } + + private static CompoundTag inventoryStack(CompoundTag player) { + return stack(player, "Inventory", 0); + } + + private static CompoundTag stack(CompoundTag player, String inventory, int index) { + return player.getListOrEmpty(inventory).getCompoundOrEmpty(index); + } + + private static void addPlayerWitnesses(CompoundTag player) { + ListTag inventory = player.getListOrEmpty("Inventory"); + inventory.add(legacyBook(9, "example:precision", 3)); + CompoundTag wool = new CompoundTag(); + wool.putString("id", "minecraft:wool"); + wool.putByte("Count", (byte) 7); + wool.putByte("Slot", (byte) 12); + wool.putShort("Damage", (short) 14); + inventory.add(wool); + ListTag enderItems = new ListTag(); + enderItems.add(legacyBook(5, "example:ward", 2)); + player.put("EnderItems", enderItems); + } + + private static CompoundTag legacyBook(int slot, String name, int level) { + CompoundTag book = new CompoundTag(); + book.putString("id", "minecraft:enchanted_book"); + book.putByte("Count", (byte) 1); + book.putByte("Slot", (byte) slot); + CompoundTag enchantment = new CompoundTag(); + enchantment.putString("id", name); + enchantment.putShort("lvl", (short) level); + ListTag enchantments = new ListTag(); + enchantments.add(enchantment); + CompoundTag tag = new CompoundTag(); + tag.put("StoredEnchantments", enchantments); + book.put("tag", tag); + return book; + } + + private static CompoundTag storedEnchantment(CompoundTag stack) { + ListTag enchantments = stack.getCompoundOrEmpty("tag").getListOrEmpty("StoredEnchantments"); + assertEquals(1, enchantments.size()); + return enchantments.getCompoundOrEmpty(0); + } + + private static void assertLegacyWitnesses(CompoundTag result) { + assertEquals(3, result.getListOrEmpty("Inventory").size()); + assertEquals(1, result.getListOrEmpty("EnderItems").size()); + CompoundTag wool = stack(result, "Inventory", 2); + assertEquals("minecraft:red_wool", wool.getStringOr("id", "")); + assertEquals(7, wool.getByteOr("Count", (byte) 0)); + assertEquals(12, wool.getByteOr("Slot", (byte) 0)); + assertFalse(wool.contains("count")); + assertFalse(wool.contains("components")); + } + + private static void restoreBookName(CompoundTag source, CompoundTag result, String inventory, int index, + String name, int level) { + CompoundTag before = stack(source, inventory, index); + CompoundTag after = stack(result, inventory, index); + assertEquals("minecraft:enchanted_book", after.getStringOr("id", "")); + assertEquals(before.get("Count"), after.get("Count")); + assertEquals(before.get("Slot"), after.get("Slot")); + assertFalse(after.contains("count")); + assertFalse(after.contains("components")); + assertEquals(name, storedEnchantment(before).getStringOr("id", "")); + assertEquals(level, storedEnchantment(after).getIntOr("lvl", 0)); + storedEnchantment(after).putString("id", name); + } + + private static void assertComponentBook(CompoundTag stack, int slot, String name, int level) { + assertEquals("minecraft:enchanted_book", stack.getStringOr("id", "")); + assertEquals(1, stack.getIntOr("count", 0)); + assertEquals(slot, stack.getByteOr("Slot", (byte) 0)); + assertFalse(stack.contains("Count")); + assertFalse(stack.contains("tag")); + CompoundTag levels = stack.getCompoundOrEmpty("components").getCompoundOrEmpty("minecraft:stored_enchantments"); + assertFalse(levels.contains("levels"), "The native enchantment map must not retain the pre-1.21.5 wrapper"); + assertEquals(1, levels.size()); + assertEquals(level, levels.getIntOr(name, 0)); + } + + private static CompoundTag legacyStack() { + CompoundTag stack = new CompoundTag(); + stack.putString("id", "example:wrench"); + stack.putByte("Count", (byte) 1); + stack.putByte("Slot", (byte) 4); + stack.putShort("Damage", (short) 3); + return stack; + } + + private static void prepareStack(CompoundTag stack) { + CompoundTag itemData = new CompoundTag(); + itemData.putInt("Damage", stack.getShortOr("Damage", (short) 0)); + itemData.putInt("companionCharge", 42); + stack.put("tag", itemData); + } + + private static void assertComponentStack(CompoundTag stack) { + assertEquals("example:wrench", stack.getStringOr("id", "")); + assertEquals(1, stack.getIntOr("count", 0)); + assertEquals(4, stack.getByteOr("Slot", (byte) 0)); + assertFalse(stack.contains("Count")); + assertFalse(stack.contains("tag")); + CompoundTag components = stack.getCompoundOrEmpty("components"); + assertEquals(3, components.getIntOr("minecraft:damage", 0)); + assertEquals(42, components.getCompoundOrEmpty("minecraft:custom_data").getIntOr("companionCharge", 0)); + } + + private static CompoundTag chunk(int block, int metadata) { + CompoundTag root = new CompoundTag(); + root.putInt("DataVersion", 1343); + CompoundTag level = new CompoundTag(); + level.putBoolean("TerrainPopulated", true); + level.putBoolean("LightPopulated", true); + level.putByteArray("Biomes", new byte[256]); + level.putIntArray("HeightMap", new int[256]); + CompoundTag section = new CompoundTag(); + byte[] blocks = new byte[4096]; + blocks[0] = (byte) block; + section.putByteArray("Blocks", blocks); + byte[] data = new byte[2048]; + data[0] = (byte) metadata; + section.putByteArray("Data", data); + section.putByte("Y", (byte) 0); + ListTag sections = new ListTag(); + sections.add(section); + level.put("Sections", sections); + ListTag blockEntities = new ListTag(); + if (block == 54) { + CompoundTag chest = new CompoundTag(); + chest.putString("id", "minecraft:chest"); + ListTag items = new ListTag(); + items.add(legacyStack()); + chest.put("Items", items); + blockEntities.add(chest); + } + level.put("TileEntities", blockEntities); + level.put("Entities", new ListTag()); + root.put("Level", level); + return root; + } +} diff --git a/src/test/java/zone/moddev/mc/basemetals/migration/NativeMigrationHooksTest.java b/src/test/java/zone/moddev/mc/basemetals/migration/NativeMigrationHooksTest.java index 77d5c04b0..8da0b96a2 100644 --- a/src/test/java/zone/moddev/mc/basemetals/migration/NativeMigrationHooksTest.java +++ b/src/test/java/zone/moddev/mc/basemetals/migration/NativeMigrationHooksTest.java @@ -2,6 +2,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import java.lang.reflect.Modifier; import java.nio.file.Path; @@ -23,6 +25,43 @@ /** Catches renamed injection targets before a packaged world is opened. */ class NativeMigrationHooksTest { + @Test + void chunkFinalizationCannotCancelCompanionReturnHooks() throws Exception { + var node = new org.objectweb.asm.tree.ClassNode(); + try (var input = getClass().getResourceAsStream( + "/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.class")) { + new org.objectweb.asm.ClassReader(input).accept(node, 0); + } + var finish = node.methods.stream().filter(method -> method.name.equals("basemetals$finishChunk")) + .findFirst().orElseThrow(); + var annotations = new java.util.ArrayList(); + if (finish.visibleAnnotations != null) annotations.addAll(finish.visibleAnnotations); + if (finish.invisibleAnnotations != null) annotations.addAll(finish.invisibleAnnotations); + var inject = annotations.stream().filter(annotation -> annotation.desc.endsWith("/Inject;")) + .findFirst().orElseThrow(); + int cancellation = inject.values.indexOf("cancellable"); + assertTrue(cancellation < 0 || !Boolean.TRUE.equals(inject.values.get(cancellation + 1))); + for (var instruction : finish.instructions.toArray()) { + if (instruction instanceof org.objectweb.asm.tree.MethodInsnNode call) { + assertFalse(call.name.equals("setReturnValue") || call.name.equals("cancel")); + } + } + } + + @Test + void finalizationEditsTheSameCompoundAndRetainsUnownedData() throws Exception { + var field = LegacyWorldDataHook.class.getDeclaredField("PRESERVE_CHUNK_MARKER"); + field.setAccessible(true); + String marker = (String) field.get(null); + CompoundTag root = new CompoundTag(); + root.putBoolean(marker, true); + root.putString("UnownedWitness", "retain exactly"); + assertSame(root, LegacyWorldDataHook.finalizeLegacyChunk(root)); + assertFalse(root.contains(marker)); + assertEquals("full", root.getString("Status").orElseThrow()); + assertEquals("retain exactly", root.getString("UnownedWitness").orElseThrow()); + } + @Test void levelCaptureStillRunsBeforeVanillaConversion() throws Exception { var method = LevelStorageSource.class.getDeclaredMethod(