diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68ffd2408..1d895ea9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,9 +68,9 @@ jobs: --no-daemon --stacktrace first="$RUNNER_TEMP/basemetals-first" mkdir -p "$first" - cp build/libs/BaseMetals-3.1.1.120061.jar "$first/" - cp build/libs/BaseMetals-3.1.1.120061-sources.jar "$first/" - cp build/libs/BaseMetals-3.1.1.120061-javadoc.jar "$first/" + cp build/libs/BaseMetals-3.1.2.120061.jar "$first/" + cp build/libs/BaseMetals-3.1.2.120061-sources.jar "$first/" + cp build/libs/BaseMetals-3.1.2.120061-javadoc.jar "$first/" cp build/release/SHA256SUMS "$first/" - name: Rebuild and prove byte-identical archives @@ -78,9 +78,9 @@ jobs: ./gradlew clean assemble verifyReleaseArtifacts writeReleaseChecksums \ -PorespawnVerificationRepository="${{ steps.orespawn.outputs.repository }}" \ --no-daemon --stacktrace - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.120061.jar" build/libs/BaseMetals-3.1.1.120061.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.120061-sources.jar" build/libs/BaseMetals-3.1.1.120061-sources.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.120061-javadoc.jar" build/libs/BaseMetals-3.1.1.120061-javadoc.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.2.120061.jar" build/libs/BaseMetals-3.1.2.120061.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.2.120061-sources.jar" build/libs/BaseMetals-3.1.2.120061-sources.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.2.120061-javadoc.jar" build/libs/BaseMetals-3.1.2.120061-javadoc.jar cmp "$RUNNER_TEMP/basemetals-first/SHA256SUMS" build/release/SHA256SUMS - name: Run all required GameTests @@ -146,9 +146,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/BaseMetals-3.1.1.120061.jar - build/libs/BaseMetals-3.1.1.120061-sources.jar - build/libs/BaseMetals-3.1.1.120061-javadoc.jar + build/libs/BaseMetals-3.1.2.120061.jar + build/libs/BaseMetals-3.1.2.120061-sources.jar + build/libs/BaseMetals-3.1.2.120061-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a81d99317..3922f6a2d 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,8 @@ +Base Metals 3.1.2.120061 + +- Let companion mods preserve their own item and block data during legacy world upgrades. +- Preserve custom names and enchantments on old player items before Minecraft converts them to components. + Base Metals 3.1.1.120061 - Fix startup failures when Base Metals is added to an existing modern world, including worlds with LZ4-compressed chunks, external chunks or empty region files. diff --git a/README.md b/README.md index 925b4ff1a..c886f28cb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,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.20.6 Forge version, `3.1.1.120061`. It keeps the +This is the Minecraft 1.20.6 Forge version, `3.1.2.120061`. It keeps the fixes and features from the released 1.20.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 Additional Loot @@ -26,7 +26,7 @@ Tables are no longer needed. - OreSpawn `[4.1.0.120061,5.0.0)` on both client and server This version uses OreSpawn `4.1.0.120061`. Put -`BaseMetals-3.1.1.120061.jar` and a compatible OreSpawn JAR in the `mods` +`BaseMetals-3.1.2.120061.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. @@ -189,7 +189,7 @@ artifacts. GameTests, smoke probes, migration fixtures, and reference sources are isolated from the published JAR. Release artifacts use Maven coordinate -`zone.moddev.mc.basemetals:BaseMetals:3.1.1.120061`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.2.120061`. 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 fce38fd5b..bc19045ac 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -26,9 +26,9 @@ summary or that no legacy content was present; subsequent starts do nothing. The packaged fallback map covers the exact public 1.12 runtime. With a saved legacy registry map, blocks from the world's other 1.10/1.12 mods also retain -their registry name and original block-entity data, although block states -without a Base Metals-owned metadata decoder fall back to that mod's default -state. Base Metals and historical `mmdlib:*` states retain their old metadata +their registry name and original block-entity data. Without a Base Metals or +companion metadata decoder, those blocks use the registered default state. +Base Metals and historical `mmdlib:*` states retain their old metadata semantics, including complete paired-door state. If neither legacy level file contains an ID used by a numeric chunk, migration stops instead of guessing or deleting it. Restore the backed-up regions and recover the world's original @@ -58,6 +58,22 @@ upgrade start. Missing mappings also forward: - historical `mmdlib:*` Vanilla Bits blocks, items, and fluids to matching `basemetals:*` IDs. +### Companion mods + +Register a `Legacy112MigrationExtension` with a unique ID during common setup. +The extension runs only for a legacy Base Metals world that has not completed +pre-flattening. It can prepare legacy chunks and player NBT and decode raw +four-bit block metadata for its own blocks. It does not replace Base Metals' +decoders. Multiple claims for a block or a decoded air state stop conversion. + +Keep stacks in vanilla-recognized inventories in legacy `Count` and `tag` +format during preparation. Minecraft's data fixer converts them to `count` and +`components` on load. The embedded player callback receives the prepared +legacy snapshot and the current componentized result. Detached playerdata runs +only the prepare callback; vanilla loads and fixes that file later. Vanilla +does not fix arbitrary item stacks inside opaque mod block-entity or player +data; the companion mod owns those formats. + ## Moving from 1.13 through 1.19 These worlds already use named block states, so they do not need the numeric diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index e1e94d0a0..c7f53840f 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -1,6 +1,6 @@ # Supported versions -Base Metals `3.1.1.120061` targets Minecraft 1.20.6, Forge 50.2.0 and Java 21. +Base Metals `3.1.2.120061` targets Minecraft 1.20.6, Forge 50.2.0 and Java 21. OreSpawn `4.1.0.120061` is the exact build and test dependency. Clients and servers need OreSpawn `[4.1.0.120061,5.0.0)`. @@ -10,7 +10,7 @@ Its SHA-256 is `FAA7107B9FB84E2C62ED847CAFD880AA3C4238A37799BAD955EA9602A944AA26`. Release artifacts use Maven coordinate -`zone.moddev.mc.basemetals:BaseMetals:3.1.1.120061`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.2.120061`. 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 3dd4b5ebd..1145cb4a2 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.120061 +mod_version=3.1.2.120061 # 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 7faf6bd9d..874da462a 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.120061"')) { + if (metadata.contains('${') || !metadata.contains('version="3.1.2.120061"')) { 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 59d0b1f4e..2d3956334 100644 --- a/gradle/release/artifacts.gradle +++ b/gradle/release/artifacts.gradle @@ -201,7 +201,7 @@ tasks.register('verifyReleaseConfiguration') { dependsOn tasks.named('verifyMavenizerCompatibilityFixture') dependsOn tasks.named('verifyJava21Toolchain') doLast { - if (configuredReleaseIdentity.modVersion != '3.1.1.120061' + if (configuredReleaseIdentity.modVersion != '3.1.2.120061' || configuredReleaseIdentity.modGroup != expectedMavenGroup || configuredReleaseIdentity.minecraftVersion != '1.20.6' || configuredReleaseIdentity.forgeVersion != '50.2.0' @@ -320,7 +320,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.120061"') + || !modsToml.contains('version="3.1.2.120061"') || !modsToml.contains('versionRange="[4.1.0.120061,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 94d005af4..9d95cf972 100644 --- a/gradle/verification/packaged-runtime.gradle +++ b/gradle/verification/packaged-runtime.gradle @@ -124,7 +124,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.120061' + if (values.getProperty('basemetals') != '3.1.2.120061' || values.getProperty('orespawn') != '4.1.0.120061' || values.getProperty('forge') != '50.2.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..57647e51f --- /dev/null +++ b/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java @@ -0,0 +1,46 @@ +package zone.moddev.mc.basemetals.api.migration; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.state.BlockState; +import zone.moddev.mc.basemetals.migration.Legacy112WorldMigrator; + +/** + * Lets a companion mod preserve its data when Base Metals pre-flattens a + * Minecraft 1.10 or 1.12 world. Register during common setup, before worlds load. + */ +public interface Legacy112MigrationExtension { + enum Payload { CHUNK, PLAYER } + + /** + * Updates a legacy chunk or player payload in place, before vanilla's data + * fixer or the migrator's block entity snapshot. Leave stacks that vanilla + * recognizes in the legacy item format so its fixer can componentize them. + * Return true on mutation. + */ + default boolean prepareData(CompoundTag tag, int dataVersion, Payload payload) { return false; } + + /** Runs immediately before the intermediate chunk data fix, only for chunks being converted. */ + default void beforeChunkFlattening(CompoundTag chunk) { } + + /** + * Checks or restores extension data after vanilla flattens the embedded + * single-player record to the 1.20.1 schema (data version 3465). The prepared + * source is a read-only legacy snapshot; the mutable result still stores + * items as {@code Count} and {@code tag}. Base Metals runs the remaining native data fixes, + * including item component conversion, after all callbacks return. Vanilla + * does not componentize arbitrary stacks inside opaque mod payloads. + */ + default void afterPlayerFlattening(CompoundTag preparedSource, CompoundTag result) { } + + /** + * Returns the native state for an owned legacy block, or null for an ID + * this extension does not own. Metadata is the original four-bit value. + */ + default BlockState decodeBlockState(ResourceLocation legacyId, int metadata) { return null; } + + /** The ID identifies the registering extension and must be unique. */ + static void register(ResourceLocation 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 feaf84490..6f4fc66d8 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(ResourceLocation 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.getCompound("Data"); - if (!data.contains("Player", Tag.TAG_COMPOUND)) return; - int version = data.getInt("DataVersion"); - CompoundTag corrected = DataFixTypes.PLAYER.updateToCurrentVersion(DataFixers.getDataFixer(), - data.getCompound("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.getCompound("Data"); + if (!data.contains("Player", Tag.TAG_COMPOUND)) return; + corrected = DataFixTypes.PLAYER.updateToCurrentVersion(DataFixers.getDataFixer(), + data.getCompound("Player"), data.getInt("DataVersion")); + } for (String key : List.copyOf(loaded.getAllKeys())) 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.getInt("DataVersion"); 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.getCompound("Level"); ListTag sections = level.getList("Sections", Tag.TAG_COMPOUND); 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.getCompound("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.getCompound("Data"); + if (!extensions.isEmpty() && isLegacyLevel(data) && world.contains("Player", Tag.TAG_COMPOUND)) { + CompoundTag player = world.getCompound("Player"); + int version = world.getInt("DataVersion"); + 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.getInt("DataVersion"), 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.getCompound("Level").getList("Sections", Tag.TAG_COMPOUND); 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()); ResourceLocation oldId = ResourceLocation.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/packagedClientSmoke/packaged-resources/META-INF/mods.toml b/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml index 743c7b0ef..65bc9c481 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.120061]" +versionRange="[3.1.2.120061]" 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 96fd6e59c..0b19880d6 100644 --- a/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java +++ b/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java @@ -38,7 +38,7 @@ public PackagedServerSmoke() { public void onServerStarted(ServerStartedEvent event) throws IOException { String baseMetals = version("basemetals"); String oreSpawn = version("orespawn"); - if (!"3.1.1.120061".equals(baseMetals) || !"4.1.0.120061".equals(oreSpawn)) { + if (!"3.1.2.120061".equals(baseMetals) || !"4.1.0.120061".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 3f2e306ab..4ac65ad85 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.120061]" +versionRange="[3.1.2.120061]" 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..b9996266b --- /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.ResourceLocation; +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.getCompound("Level").getList("TileEntities", Tag.TAG_COMPOUND).getCompound(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.getList("Items", Tag.TAG_COMPOUND).getCompound(0); + prepareStack(stack); + calls.add("prepare"); + return true; + } + @Override public void beforeChunkFlattening(CompoundTag source) { + assertSame(chunk, source); + assertEquals(3, chest.getList("Items", Tag.TAG_COMPOUND).getCompound(0).getCompound("tag").getInt("Damage")); + calls.add("flatten"); + } + }; + CompoundTag fixed = Legacy112WorldMigrator.convertChunkForTest(chunk, + Map.of(0, "minecraft:air", 54, "minecraft:chest"), List.of(extension)); + CompoundTag stack = fixed.getCompound("Level").getList("TileEntities", Tag.TAG_COMPOUND) + .getCompound(0).getList("Items", Tag.TAG_COMPOUND).getCompound(0); + assertEquals(List.of("prepare", "flatten"), calls); + assertEquals("example:wrench", stack.getString("id")); + assertEquals(1, stack.getByte("Count")); + assertEquals(4, stack.getByte("Slot")); + assertEquals(3, stack.getCompound("tag").getInt("Damage")); + + CompoundTag current = DataFixTypes.CHUNK.updateToCurrentVersion( + DataFixers.getDataFixer(), fixed, fixed.getInt("DataVersion")); + assertComponentStack(current.getList("block_entities", Tag.TAG_COMPOUND) + .getCompound(0).getList("Items", Tag.TAG_COMPOUND).getCompound(0)); + } + + @Test + void unclaimedThirdPartyBlocksStillKeepTheirNameAndTheDecoderReceivesRawMetadata() throws Exception { + List metadata = new ArrayList<>(); + Legacy112MigrationExtension extension = new Legacy112MigrationExtension() { + @Override public BlockState decodeBlockState(ResourceLocation id, int data) { + assertEquals(ResourceLocation.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).getString("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.getCompound("Level").getList("TileEntities", Tag.TAG_COMPOUND).add(entity); + Legacy112MigrationExtension extension = new Legacy112MigrationExtension() { + @Override public void beforeChunkFlattening(CompoundTag source) { + source.getCompound("Level").getList("TileEntities", Tag.TAG_COMPOUND) + .getCompound(0).putInt("energy", 125); + } + + @Override public BlockState decodeBlockState(ResourceLocation 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.getString("Name")); + assertEquals("east", state.getCompound("Properties").getString("facing")); + CompoundTag saved = fixed.getCompound("Level").getList("TileEntities", Tag.TAG_COMPOUND).getCompound(0); + assertEquals("example:machine", saved.getString("id")); + assertEquals(125, saved.getInt("energy")); + } + + @Test + void rejectsAirAndAmbiguousDecoders() { + Legacy112MigrationExtension air = new Legacy112MigrationExtension() { + @Override public BlockState decodeBlockState(ResourceLocation id, int data) { + return Blocks.AIR.defaultBlockState(); + } + }; + Legacy112MigrationExtension stone = new Legacy112MigrationExtension() { + @Override public BlockState decodeBlockState(ResourceLocation 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()) + .getCompound("Data").getCompound("Player"); + assertEquals(expected, restored); + assertEquals(7, stack(restored, "Inventory", 2).getInt("count")); + assertEquals("minecraft:red_wool", stack(restored, "Inventory", 2).getString("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).getCompound("tag").getInt("Damage")); + assertLegacyWitnesses(result); + assertEquals(3, inventoryStack(result).getCompound("tag").getInt("Damage")); + assertEquals("minecraft:wool", stack(source, "Inventory", 2).getString("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.getInt("companionEnergy")); + assertEquals("example:precision", storedEnchantment(stack(result, "Inventory", 1)).getString("id")); + assertEquals("example:ward", storedEnchantment(stack(result, "EnderItems", 0)).getString("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()).getCompound("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.getString("id")); + assertEquals(7, wool.getInt("count")); + assertEquals(12, wool.getByte("Slot")); + assertFalse(wool.contains("Count")); + assertEquals(125, loaded.getInt("companionEnergy")); + assertEquals(rewritten ? currentVersion() : version, diskData.getInt("DataVersion")); + if (rewritten) { + assertEquals(loaded, diskData.getCompound("Player")); + } else { + CompoundTag stack = inventoryStack(diskData.getCompound("Player")); + assertEquals(3, stack.getCompound("tag").getInt("Damage")); + assertFalse(stack.contains("components")); + assertFalse(diskData.getCompound("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.getInt("DataVersion")); + assertComponentStack(inventoryStack(DataFixTypes.PLAYER.updateToCurrentVersion( + DataFixers.getDataFixer(), prepared, 1343))); + } + + private static int currentVersion() { + return SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + } + + 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.getList(inventory, Tag.TAG_COMPOUND).getCompound(index); + } + + private static void addPlayerWitnesses(CompoundTag player) { + ListTag inventory = player.getList("Inventory", Tag.TAG_COMPOUND); + 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.getCompound("tag").getList("StoredEnchantments", Tag.TAG_COMPOUND); + assertEquals(1, enchantments.size()); + return enchantments.getCompound(0); + } + + private static void assertLegacyWitnesses(CompoundTag result) { + assertEquals(3, result.getList("Inventory", Tag.TAG_COMPOUND).size()); + assertEquals(1, result.getList("EnderItems", Tag.TAG_COMPOUND).size()); + CompoundTag wool = stack(result, "Inventory", 2); + assertEquals("minecraft:red_wool", wool.getString("id")); + assertEquals(7, wool.getByte("Count")); + assertEquals(12, wool.getByte("Slot")); + 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.getString("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).getString("id")); + assertEquals(level, storedEnchantment(after).getInt("lvl")); + storedEnchantment(after).putString("id", name); + } + + private static void assertComponentBook(CompoundTag stack, int slot, String name, int level) { + assertEquals("minecraft:enchanted_book", stack.getString("id")); + assertEquals(1, stack.getInt("count")); + assertEquals(slot, stack.getByte("Slot")); + assertFalse(stack.contains("Count")); + assertFalse(stack.contains("tag")); + CompoundTag levels = stack.getCompound("components").getCompound("minecraft:stored_enchantments") + .getCompound("levels"); + assertEquals(1, levels.size()); + assertEquals(level, levels.getInt(name)); + } + + 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.getShort("Damage")); + itemData.putInt("companionCharge", 42); + stack.put("tag", itemData); + } + + private static void assertComponentStack(CompoundTag stack) { + assertEquals("example:wrench", stack.getString("id")); + assertEquals(1, stack.getInt("count")); + assertEquals(4, stack.getByte("Slot")); + assertFalse(stack.contains("Count")); + assertFalse(stack.contains("tag")); + CompoundTag components = stack.getCompound("components"); + assertEquals(3, components.getInt("minecraft:damage")); + assertEquals(42, components.getCompound("minecraft:custom_data").getInt("companionCharge")); + } + + 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; + } +}