From 6d1ad4359f617ad8b3733470af2a040889d7f4cc Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Sun, 25 Oct 2026 05:38:27 +0000 Subject: [PATCH] Fix world migration checks in 1.20.6 Release 3.1.1.120061 with safer old-world detection, current-player preservation and native chunk reading. --- .github/workflows/ci.yml | 18 +- CHANGELOG.txt | 6 + README.md | 10 +- docs/MIGRATION.md | 16 + docs/VERSIONS.md | 4 +- gradle.properties | 2 +- gradle/ide/eclipse.gradle | 2 +- gradle/release/artifacts.gradle | 4 +- gradle/verification/packaged-runtime.gradle | 2 +- .../migration/Legacy112WorldMigrator.java | 178 ++++--- .../migration/LegacyWorldDataHook.java | 31 +- .../packaged-resources/META-INF/mods.toml | 2 +- .../basemetals/smoke/PackagedServerSmoke.java | 2 +- .../packaged-resources/META-INF/mods.toml | 2 +- .../migration/LegacyMigrationSafetyTest.java | 495 ++++++++++++++++++ 15 files changed, 680 insertions(+), 94 deletions(-) create mode 100644 src/test/java/zone/moddev/mc/basemetals/migration/LegacyMigrationSafetyTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c46143bc4..68ffd2408 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.0.120061.jar "$first/" - cp build/libs/BaseMetals-3.1.0.120061-sources.jar "$first/" - cp build/libs/BaseMetals-3.1.0.120061-javadoc.jar "$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/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.0.120061.jar" build/libs/BaseMetals-3.1.0.120061.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.0.120061-sources.jar" build/libs/BaseMetals-3.1.0.120061-sources.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.0.120061-javadoc.jar" build/libs/BaseMetals-3.1.0.120061-javadoc.jar + 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/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.0.120061.jar - build/libs/BaseMetals-3.1.0.120061-sources.jar - build/libs/BaseMetals-3.1.0.120061-javadoc.jar + 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/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 796cb2ceb..a81d99317 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,9 @@ +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. +- Stop old level.dat_old backups from replacing current player data or enabling legacy terrain protection. +- Keep genuine legacy upgrades, reloads and later-opened chunks supported, including chunks in nested custom dimensions. + Base Metals 3.1.0.120061 - Port the released 1.20.1 version to Minecraft 1.20.6, Forge 50.2.0 and Java 21. diff --git a/README.md b/README.md index a62c98279..925b4ff1a 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.0.120061`. It keeps the +This is the Minecraft 1.20.6 Forge version, `3.1.1.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.0.120061.jar` and a compatible OreSpawn JAR in the `mods` +`BaseMetals-3.1.1.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. @@ -130,6 +130,10 @@ is unavailable. Always perform the first upgrade on a copy of the world and keep the untouched original. Read [Migration](docs/MIGRATION.md) before starting an upgraded world. +Adding Base Metals to a modern world does not trigger the old numeric-ID +conversion just because an older backup is present. Existing player data stays +in place; genuine legacy upgrades still make backups before changing saved data. + ## Mod and data-pack compatibility Forge item, block, and fluid tags are Base Metals' public compatibility API. @@ -185,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.0.120061`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.1.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 58e5cf230..fce38fd5b 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -112,3 +112,19 @@ dimensions, and heights. The generated manifests at `data/basemetals/registry_manifest_1_12.json` and `data/basemetals/registry_manifest.json` document the runtime baseline, restored content, and explicit 1.18 additions. + +## Existing modern worlds + +Modern worlds do not need the old numeric-ID conversion. Base Metals only runs +it after confirming an older source save. A leftover `level.dat_old` does not +replace the current player or turn on legacy terrain protection. + +For a genuine upgrade, the original single-player record is captured before +Minecraft reads it. The world-local migration sidecar keeps the source version +and saved registry map for reloads and chunks opened later; it does not store +a player record. A saved registry map from an earlier upgrade takes precedence +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. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index a83f07380..e1e94d0a0 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -1,6 +1,6 @@ # Supported versions -Base Metals `3.1.0.120061` targets Minecraft 1.20.6, Forge 50.2.0 and Java 21. +Base Metals `3.1.1.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.0.120061`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.1.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 302aa7bdc..3dd4b5ebd 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.0.120061 +mod_version=3.1.1.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 1b0e1af20..7faf6bd9d 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.0.120061"')) { + if (metadata.contains('${') || !metadata.contains('version="3.1.1.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 8af325a0a..59d0b1f4e 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.0.120061' + if (configuredReleaseIdentity.modVersion != '3.1.1.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.0.120061"') + || !modsToml.contains('version="3.1.1.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 95288af53..94d005af4 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.0.120061' + if (values.getProperty('basemetals') != '3.1.1.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/migration/Legacy112WorldMigrator.java b/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java index 8f9be9365..feaf84490 100644 --- a/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java +++ b/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java @@ -1,6 +1,5 @@ package zone.moddev.mc.basemetals.migration; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; @@ -24,8 +23,6 @@ import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.zip.GZIPInputStream; -import java.util.zip.InflaterInputStream; import java.util.zip.DeflaterOutputStream; import zone.moddev.mc.basemetals.BaseMetals; @@ -65,6 +62,10 @@ import net.minecraft.world.level.block.state.properties.Half; import net.minecraft.world.level.block.state.properties.SlabType; import net.minecraft.world.level.block.state.properties.StairsShape; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.storage.RegionFile; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.storage.RegionStorageInfo; import net.minecraftforge.registries.ForgeRegistries; /** @@ -80,14 +81,57 @@ public final class Legacy112WorldMigrator { 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 Path preparedWorld; + private static CompoundTag preparedPlayer; + private static int preparedVersion; private Legacy112WorldMigrator() {} + static void beginWorldLoad() { + preparedWorld = null; + preparedPlayer = null; + preparedVersion = 0; + } + + /** Keep the original player before Forge and vanilla read the old save. */ + static void prepareWorld(Path levelDat, CompoundTag root) { + if (!isLegacyLevel(root)) return; + + CompoundTag data = root.getCompound("Data"); + preparedWorld = levelDat.toAbsolutePath().normalize().getParent(); + preparedVersion = data.getInt("DataVersion"); + preparedPlayer = data.contains("Player", Tag.TAG_COMPOUND) ? data.getCompound("Player").copy() : null; + } + + private static boolean isLegacyLevel(CompoundTag root) { + CompoundTag data = root.getCompound("Data"); + return data.contains("DataVersion", Tag.TAG_ANY_NUMERIC) + && data.getInt("DataVersion") <= LAST_SUPPORTED_LEGACY_DATA_VERSION; + } + + private static boolean hasLegacySource(Path root) throws IOException { + if (root.equals(preparedWorld) || isLegacyLevel(LegacyNbt.readCompressed(root.resolve("level.dat").toFile()))) { + return true; + } + + // This sidecar survives Forge's first rewrite. An old level.dat_old alone + // is not evidence that the current world still needs flattening. + Path sidecar = root.resolve("basemetals-legacy-registry.dat"); + if (!Files.isRegularFile(sidecar)) return false; + + CompoundTag source = LegacyNbt.readCompressed(sidecar.toFile()); + return isLegacyLevel(source) || oldBlockIds(source).values().stream() + .map(Legacy112WorldMigrator::normalizeLegacyRegistryName) + .anyMatch(id -> id.startsWith(BaseMetals.MOD_ID + ":") || id.startsWith("mmdlib:")); + } + public static void migrateIfNeeded(Path worldRoot) throws IOException { Path root = worldRoot.toAbsolutePath().normalize(); if (Files.isRegularFile(root.resolve(MARKER))) return; if (!Files.isRegularFile(root.resolve("level.dat"))) return; + if (!hasLegacySource(root)) return; + Map oldBlockIds = legacyBlockIds(root); if (oldBlockIds.values().stream().noneMatch(id -> id.startsWith(BaseMetals.MOD_ID + ":"))) return; @@ -170,12 +214,13 @@ private static Map oldBlockIds(CompoundTag level) { } private static Map legacyBlockIds(Path root) throws IOException { - for (String fileName : List.of("level.dat", "level.dat_old", "basemetals-legacy-registry.dat")) { + for (String fileName : List.of("level.dat", "basemetals-legacy-registry.dat", "level.dat_old")) { Path file = root.resolve(fileName); if (!Files.isRegularFile(file)) continue; CompoundTag candidate = LegacyNbt.readCompressed(file.toFile()); int dataVersion = candidate.getCompound("Data").getInt("DataVersion"); - if (dataVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION) continue; + if (dataVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION + && !fileName.equals("basemetals-legacy-registry.dat")) continue; Map ids = oldBlockIds(candidate); if (!ids.isEmpty()) return ids; } @@ -202,7 +247,7 @@ private static Map embeddedBlockIds() throws IOException { private static List findRegionFiles(Path root) throws IOException { List result = new ArrayList<>(); - try (var paths = Files.walk(root, 4)) { + try (var paths = Files.walk(root)) { paths.filter(Files::isRegularFile) .filter(path -> path.getFileName().toString().matches("r\\.-?\\d+\\.-?\\d+\\.mca")) .filter(path -> path.getParent() != null && path.getParent().getFileName().toString().equals("region")) @@ -229,6 +274,7 @@ private static void ensureBackupCapacity(Path root, List regions) throws I private static RegionConversion convertRegion(Path root, Path region, Map ids) 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); ByteBuffer locations = ByteBuffer.wrap(source, 0, SECTOR_BYTES).order(ByteOrder.BIG_ENDIAN); byte[] timestamps = Arrays.copyOfRange(source, SECTOR_BYTES, REGION_HEADER_BYTES); @@ -239,44 +285,65 @@ private static RegionConversion convertRegion(Path root, Path region, Map externalFiles = new java.util.LinkedHashSet<>(); + String[] coordinates = region.getFileName().toString().split("\\."); + int regionX = Integer.parseInt(coordinates[1]); + int regionZ = Integer.parseInt(coordinates[2]); + + try (RegionFile reader = new RegionFile(new RegionStorageInfo("Base Metals migration", Level.OVERWORLD, "chunk"), + region, region.getParent(), false)) { + for (int index = 0; index < 1_024; index++) { + int location = locations.getInt(index * 4); + if (location == 0) continue; + int offset = location >>> 8; + int position = offset * SECTOR_BYTES; + if (position < REGION_HEADER_BYTES || position + 5 > source.length) { + throw new IOException("Invalid chunk offset in " + region + " entry " + index); + } + int length = intAt(source, position); + if (length < 1 || position + 4 + length > source.length) { + throw new IOException("Invalid chunk length in " + region + " entry " + index); + } + int compression = source[position + 4] & 0xff; + ChunkPos chunkPos = new ChunkPos(regionX * 32 + (index & 31), regionZ * 32 + (index >>> 5)); + if ((compression & 128) != 0) { + externalFiles.add(region.getParent().resolve("c." + chunkPos.x + "." + chunkPos.z + ".mcc")); + } - for (int index = 0; index < 1_024; index++) { - int location = locations.getInt(index * 4); - if (location == 0) continue; - int offset = location >>> 8; - int position = offset * SECTOR_BYTES; - if (position < REGION_HEADER_BYTES || position + 5 > source.length) { - throw new IOException("Invalid chunk offset in " + region + " entry " + index); - } - int length = intAt(source, position); - if (length < 1 || position + 4 + length > source.length) { - throw new IOException("Invalid chunk length in " + region + " entry " + index); - } - int compression = source[position + 4] & 0xff; - byte[] payload = Arrays.copyOfRange(source, position + 5, position + 4 + length); - CompoundTag chunk = readChunk(payload, compression); - ChunkConversion conversion = convertChunk(chunk, ids); - byte[] record; - if (conversion.converted()) { - record = writeChunk(conversion.chunk()); - converted++; - externalStates += conversion.externalStates(); - preservedBlockEntities += conversion.preservedBlockEntities(); - convertedBuckets += conversion.convertedBuckets(); - convertedItemAliases += conversion.convertedItemAliases(); - convertedDamageValues += conversion.convertedDamageValues(); - } else { - record = Arrays.copyOfRange(source, position, position + 4 + length); + CompoundTag chunk; + try (DataInputStream input = reader.getChunkDataInputStream(chunkPos)) { + if (input == null) throw new IOException("Could not read chunk " + chunkPos + " in " + region); + chunk = LegacyNbt.read(input); + } + + ChunkConversion conversion = convertChunk(chunk, ids); + byte[] record; + if (conversion.converted()) { + record = writeChunk(conversion.chunk()); + converted++; + externalStates += conversion.externalStates(); + preservedBlockEntities += conversion.preservedBlockEntities(); + convertedBuckets += conversion.convertedBuckets(); + convertedItemAliases += conversion.convertedItemAliases(); + convertedDamageValues += conversion.convertedDamageValues(); + } else { + record = Arrays.copyOfRange(source, position, position + 4 + length); + } + chunks.add(new RegionChunk(index, record)); } - chunks.add(new RegionChunk(index, record)); } if (converted == 0) return new RegionConversion(0, 0, 0, 0, 0, 0); - ensureBackupCapacity(root, List.of(region)); + List originals = new ArrayList<>(externalFiles); + originals.add(region); + ensureBackupCapacity(root, originals); byte[] output = buildRegion(chunks, timestamps); - Path backup = root.resolve(BACKUP_DIRECTORY).resolve(root.relativize(region)); - Files.createDirectories(backup.getParent()); - if (!Files.exists(backup)) Files.copy(region, backup, StandardCopyOption.COPY_ATTRIBUTES); + + for (Path original : originals) { + Path backup = root.resolve(BACKUP_DIRECTORY).resolve(root.relativize(original)); + Files.createDirectories(backup.getParent()); + if (!Files.exists(backup)) Files.copy(original, backup, StandardCopyOption.COPY_ATTRIBUTES); + } Path temporary = region.resolveSibling(region.getFileName() + ".basemetals.tmp"); Files.write(temporary, output, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); @@ -358,17 +425,10 @@ private static ItemMigration migrateLooseInventories(Path root) throws IOExcepti return converted; } - /** - * Forge can rewrite level.dat before ServerAboutToStartEvent, which means - * Mojang has already interpreted a legacy mod tool's Damage field as - * flattening metadata. The untouched legacy player remains in - * level.dat_old, so rebuild only that player record through the normal - * player data fixer after first protecting mod durability and aliases. - */ + /** Restore only the player captured from this load's original legacy save. */ private static ItemMigration restoreRewrittenSingleplayerPlayer(Path root) throws IOException { Path currentFile = root.resolve("level.dat"); - Path legacyFile = root.resolve("level.dat_old"); - if (!Files.isRegularFile(currentFile) || !Files.isRegularFile(legacyFile)) { + if (!root.equals(preparedWorld) || preparedPlayer == null || !Files.isRegularFile(currentFile)) { return ItemMigration.NONE; } @@ -379,18 +439,10 @@ private static ItemMigration restoreRewrittenSingleplayerPlayer(Path root) throw return ItemMigration.NONE; } - CompoundTag legacy = LegacyNbt.readCompressed(legacyFile.toFile()); - CompoundTag legacyData = legacy.getCompound("Data"); - int legacyVersion = legacyData.getInt("DataVersion"); - if (legacyVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION - || !legacyData.contains("Player", Tag.TAG_COMPOUND)) { - return ItemMigration.NONE; - } - - CompoundTag player = legacyData.getCompound("Player").copy(); + CompoundTag player = preparedPlayer.copy(); ItemMigration migration = convertLegacyItems(player); CompoundTag fixedPlayer = DataFixTypes.PLAYER.updateToCurrentVersion(DataFixers.getDataFixer(), - player, legacyVersion); + player, preparedVersion); currentData.put("Player", fixedPlayer); Path backup = root.resolve(BACKUP_DIRECTORY).resolve(root.relativize(currentFile)); @@ -793,18 +845,6 @@ private static int nibble(byte[] values, int index) { return value >>> ((index & 1) * 4) & 15; } - private static CompoundTag readChunk(byte[] payload, int compression) throws IOException { - InputStream input = switch (compression) { - case 1 -> new GZIPInputStream(new ByteArrayInputStream(payload)); - case 2 -> new InflaterInputStream(new ByteArrayInputStream(payload)); - case 3 -> new ByteArrayInputStream(payload); - default -> throw new IOException("Unsupported region compression type " + compression); - }; - try (DataInputStream data = new DataInputStream(input)) { - return LegacyNbt.read(data); - } - } - private static byte[] writeChunk(CompoundTag chunk) throws IOException { ByteArrayOutputStream compressed = new ByteArrayOutputStream(); try (OutputStream deflater = new DeflaterOutputStream(compressed); diff --git a/src/main/java/zone/moddev/mc/basemetals/migration/LegacyWorldDataHook.java b/src/main/java/zone/moddev/mc/basemetals/migration/LegacyWorldDataHook.java index 037a3c61b..dd65498e6 100644 --- a/src/main/java/zone/moddev/mc/basemetals/migration/LegacyWorldDataHook.java +++ b/src/main/java/zone/moddev/mc/basemetals/migration/LegacyWorldDataHook.java @@ -11,6 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Arrays; import java.util.BitSet; @@ -89,6 +90,7 @@ private LegacyWorldDataHook() {} /** Called by the coremod before Forge reads level.dat. */ public static synchronized void prepareLegacyWorld(File levelDat) { + Legacy112WorldMigrator.beginWorldLoad(); legacyWorldActive = false; legacyTerrainChunks = Collections.emptyMap(); LEGACY_BASE_METALS_BLOCK_COUNTS.clear(); @@ -117,15 +119,38 @@ private static void prepareLevelMetadata(File levelDat) { try (FileInputStream input = new FileInputStream(levelDat)) { root = LegacyNbt.readCompressed(input); } + Legacy112WorldMigrator.prepareWorld(levelDat.toPath(), root); + boolean changed = removeRetiredVanillaProfessionRegistry(root); changed |= removeObsoleteVanillaBiomeSnapshot(root); changed |= migrateRemovedVanillaBiomeSources(root); + preserveLegacySource(levelDat, root); if (changed) writeWithBackup(levelDat, root); } catch (IOException exception) { throw new IllegalStateException("Could not prepare the saved world metadata in " + levelDat, exception); } } + private static void preserveLegacySource(File levelDat, CompoundTag root) throws IOException { + CompoundTag data = root.getCompound("Data"); + if (!data.contains("DataVersion", Tag.TAG_ANY_NUMERIC) || data.getInt("DataVersion") >= 2834) return; + + Path sidecar = levelDat.toPath().resolveSibling(REGISTRY_SIDECAR); + if (Files.exists(sidecar)) return; + + // Remember the source version for chunks opened on a later visit. + // Player data belongs to this load only and is deliberately not saved here. + CompoundTag saved = new CompoundTag(); + CompoundTag source = new CompoundTag(); + source.putInt("DataVersion", data.getInt("DataVersion")); + saved.put("Data", source); + if (root.contains("FML", Tag.TAG_COMPOUND)) saved.put("FML", root.getCompound("FML").copy()); + + try (java.io.OutputStream output = Files.newOutputStream(sidecar, StandardOpenOption.CREATE_NEW)) { + LegacyNbt.writeCompressed(saved, output); + } + } + static boolean removeRetiredVanillaProfessionRegistry(CompoundTag root) { boolean changed = false; for (String namespace : new String[] {"fml", "FML"}) { @@ -657,13 +682,13 @@ private static void writeWithBackup(File file, CompoundTag data) throws IOExcept } private static File legacyRegistrySource(File levelDat) { - for (File candidate : new File[] { levelDat, new File(levelDat.getParentFile(), "level.dat_old"), - new File(levelDat.getParentFile(), REGISTRY_SIDECAR) }) { + for (File candidate : new File[] { levelDat, new File(levelDat.getParentFile(), REGISTRY_SIDECAR) }) { if (!candidate.isFile()) continue; try (FileInputStream input = new FileInputStream(candidate)) { CompoundTag root = LegacyNbt.readCompressed(input); if ((root.contains("FML", 10) && containsLegacyBaseMetalsRegistryEntry(root)) - || (root.contains("Data", 10) && root.getCompound("Data").getInt("DataVersion") < 2834)) return candidate; + || (root.getCompound("Data").contains("DataVersion", Tag.TAG_ANY_NUMERIC) + && root.getCompound("Data").getInt("DataVersion") < 2834)) return candidate; } catch (IOException ignored) {} } return null; diff --git a/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml b/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml index c542d375a..743c7b0ef 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.0.120061]" +versionRange="[3.1.1.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 33e0d4574..96fd6e59c 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.0.120061".equals(baseMetals) || !"4.1.0.120061".equals(oreSpawn)) { + if (!"3.1.1.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 c12c4d618..3f2e306ab 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.0.120061]" +versionRange="[3.1.1.120061]" ordering="AFTER" side="SERVER" diff --git a/src/test/java/zone/moddev/mc/basemetals/migration/LegacyMigrationSafetyTest.java b/src/test/java/zone/moddev/mc/basemetals/migration/LegacyMigrationSafetyTest.java new file mode 100644 index 000000000..6a88ed8f5 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basemetals/migration/LegacyMigrationSafetyTest.java @@ -0,0 +1,495 @@ +package zone.moddev.mc.basemetals.migration; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import java.util.zip.InflaterInputStream; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.SharedConstants; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.storage.RegionStorageInfo; +import net.minecraft.world.level.chunk.storage.RegionFile; +import net.minecraft.world.level.chunk.storage.RegionFileVersion; + +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; + +/** Checks the migration boundary without opening a player's save. */ +class LegacyMigrationSafetyTest { + @TempDir(cleanup = CleanupMode.ON_SUCCESS) + Path temporaryWorld; + + private static final String MARKER = "BASEMETALS_1_12_BLOCK_MIGRATION_COMPLETE.txt"; + private static final String VERSION = minecraftVersion(); + private static final int DATA_VERSION = dataVersion(); + + @BeforeAll + static void gameVersion() { + SharedConstants.tryDetectVersion(); + } + + @Test + void modernDeflateWorld() throws Exception { + Path world = world("deflate", false); + Path region = region(world, false, false); + assertTrue(nativeAccepts(region, false)); + byte[] before = Files.readAllBytes(region); + + runEntry(world); + + assertArrayEquals(before, Files.readAllBytes(region)); + assertFalse(Files.exists(world.resolve(MARKER))); + } + + @Test + void modernEmptyRegion() throws Exception { + Path world = world("empty", false); + Path file = world.resolve("region/r.0.0.mca"); + Files.createDirectories(file.getParent()); + Files.createFile(file); + assertTrue(nativeAccepts(file, true)); + long nativeLength = Files.size(file); + + runEntry(world); + + assertEquals(nativeLength, Files.size(file)); + assertFalse(Files.exists(world.resolve(MARKER))); + } + + @Test + void modernExternalChunk() throws Exception { + Path world = world("external", false); + Path file = region(world, true, false); + boolean nativeAccepted = nativeAccepts(file, false); + byte[] before = Files.readAllBytes(file); + assertTrue(nativeAccepted); + runEntry(world); + assertFalse(Files.exists(world.resolve(MARKER))); + assertArrayEquals(before, Files.readAllBytes(file)); + } + + @Test + void modernLz4Chunk() throws Exception { + assumeTrue(VERSION.equals("1.20.6") || VERSION.equals("1.21.1"), "Native LZ4 is not available on older targets"); + Path world = world("lz4", false); + Path file = region(world, false, true); + assertTrue(nativeAccepts(file, false)); + byte[] before = Files.readAllBytes(file); + runEntry(world); + assertFalse(Files.exists(world.resolve(MARKER))); + assertArrayEquals(before, Files.readAllBytes(file)); + + } + + @Test + void stalePreFlatteningBackupWithNoLegacyChunks() throws Exception { + Path world = world("stale-backup", true); + writeLevel(world.resolve("level.dat_old"), 1343, true, 3); + byte[] before = Files.readAllBytes(world.resolve("level.dat")); + runEntry(world); + int experience = experience(world.resolve("level.dat")); + assertEquals(37, experience); + assertArrayEquals(before, Files.readAllBytes(world.resolve("level.dat"))); + assertFalse(Files.exists(world.resolve(MARKER))); + } + + @Test + void ordinaryModernBackupDoesNotReplacePlayer() throws Exception { + Path world = world("modern-backup", true); + writeLevel(world.resolve("level.dat_old"), DATA_VERSION, true, 3); + runEntry(world); + assertEquals(37, experience(world.resolve("level.dat"))); + } + + @Test + void existingCompletionMarkerSkipsScanner() throws Exception { + Path world = world("already-marked", true); + writeLevel(world.resolve("level.dat_old"), 1343, true, 3); + Path file = world.resolve("region/r.0.0.mca"); + Files.createDirectories(file.getParent()); + Files.createFile(file); + Files.write(world.resolve(MARKER), Collections.singletonList("Existing marker")); + runEntry(world); + assertEquals(37, experience(world.resolve("level.dat"))); + } + + @Test + void savedRegistryDoesNotMakeAnOldBackupTheCurrentPlayer() throws Exception { + Path world = world("saved-registry", true); + writeLevel(world.resolve("level.dat_old"), 1343, true, 3); + writeRegistry(world.resolve("basemetals-legacy-registry.dat")); + Path region = region(world, true, false); + byte[] regionBefore = Files.readAllBytes(region); + byte[] playerBefore = Files.readAllBytes(world.resolve("level.dat")); + byte[] externalBefore = Files.readAllBytes(region.getParent().resolve("c.0.0.mcc")); + + runEntry(world); + + assertTrue(Files.exists(world.resolve(MARKER))); + assertArrayEquals(regionBefore, Files.readAllBytes(region)); + assertArrayEquals(externalBefore, Files.readAllBytes(region.getParent().resolve("c.0.0.mcc"))); + assertArrayEquals(playerBefore, Files.readAllBytes(world.resolve("level.dat"))); + } + + @Test + void savedRegistryCanBeScannedWithAnEmptyRegion() throws Exception { + Path world = world("saved-registry-empty", false); + writeRegistry(world.resolve("basemetals-legacy-registry.dat")); + Path file = world.resolve("region/r.0.0.mca"); + Files.createDirectories(file.getParent()); + Files.createFile(file); + + runEntry(world); + + assertEquals(0, Files.size(file)); + assertTrue(Files.exists(world.resolve(MARKER))); + } + + @Test + void savedRegistryCanBeScannedWithLz4Chunks() throws Exception { + assumeTrue(VERSION.equals("1.20.6") || VERSION.equals("1.21.1")); + Path world = world("saved-registry-lz4", false); + writeRegistry(world.resolve("basemetals-legacy-registry.dat")); + Path file = region(world, false, true); + byte[] before = Files.readAllBytes(file); + + runEntry(world); + + assertArrayEquals(before, Files.readAllBytes(file)); + assertTrue(Files.exists(world.resolve(MARKER))); + } + + @Test + void firstUpgradeUsesThePlayerCapturedBeforeForgeReadsTheWorld() throws Exception { + Path world = world("first-legacy-upgrade", true); + writeLevel(world.resolve("level.dat"), 1343, true, 7); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + + // Forge has now rewritten level.dat. Its backup is not our source of player data. + writeLevel(world.resolve("level.dat"), DATA_VERSION, true, 37); + writeLevel(world.resolve("level.dat_old"), 1343, true, 3); + runEntry(world); + + assertEquals(7, experience(world.resolve("level.dat"))); + assertTrue(Files.exists(world.resolve("basemetals-1.12-region-backup/level.dat"))); + assertTrue(Files.exists(world.resolve(MARKER))); + + writeLevel(world.resolve("level.dat"), DATA_VERSION, true, 38); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + runEntry(world); + assertEquals(38, experience(world.resolve("level.dat"))); + } + + @Test + void openingAnotherWorldClearsTheCapturedPlayer() throws Exception { + Path oldWorld = world("old-world", true); + writeLevel(oldWorld.resolve("level.dat"), 1343, true, 7); + LegacyWorldDataHook.prepareLegacyWorld(oldWorld.resolve("level.dat").toFile()); + + Path modernWorld = world("next-modern-world", true); + LegacyWorldDataHook.prepareLegacyWorld(modernWorld.resolve("level.dat").toFile()); + writeLevel(modernWorld.resolve("level.dat_old"), 1343, true, 3); + runEntry(modernWorld); + + assertEquals(37, experience(modernWorld.resolve("level.dat"))); + assertFalse(Files.exists(modernWorld.resolve(MARKER))); + } + + @Test + void anOldBackupDoesNotActivateTerrainProtectionInAModernWorld() throws Exception { + for (int backupVersion : new int[] {1343, 2730}) { + Path world = world("old-terrain-backup", true); + writeLevel(world.resolve("level.dat_old"), backupVersion, true, 3); + region(world, false, false); + + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + + Field active = LegacyWorldDataHook.class.getDeclaredField("legacyWorldActive"); + active.setAccessible(true); + assertFalse(active.getBoolean(null), "Backup version " + backupVersion); + assertEquals(37, experience(world.resolve("level.dat"))); + } + } + + @Test + void terrainProtectionSurvivesReloadWithoutTrustingAnOldBackup() throws Exception { + Path world = world("flattened-upgrade", false); + writeLevel(world.resolve("level.dat"), 2730, false, 0); + region(world, false, false); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + + assertTrue(Files.exists(world.resolve("basemetals-legacy-registry.dat"))); + writeLevel(world.resolve("level.dat"), DATA_VERSION, false, 0); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + + Field active = LegacyWorldDataHook.class.getDeclaredField("legacyWorldActive"); + active.setAccessible(true); + assertTrue(active.getBoolean(null)); + runEntry(world); + assertFalse(Files.exists(world.resolve(MARKER))); + } + + @Test + void mixedRegionsAndLaterOpenedCustomDimensionChunksKeepTheirContents() throws Exception { + Path world = world("mixed-legacy-chunks", true); + writeRegistry(world.resolve("basemetals-legacy-registry.dat")); + writeLevel(world.resolve("level.dat_old"), 1343, true, 3); + Path mixed = region(world, true, false); + Path external = mixed.getParent().resolve("c.0.0.mcc"); + byte[] externalBefore = Files.readAllBytes(external); + appendLegacyChunk(mixed, 1, 1); + byte[] mixedBefore = Files.readAllBytes(mixed); + + Path later = world.resolve("dimensions/example/nested/cavern/region/r.2.0.mca"); + Files.createDirectories(later.getParent()); + Files.write(later, new byte[8192]); + appendLegacyChunk(later, 0, 64); + + runEntry(world); + + for (Path file : Arrays.asList(mixed, later)) { + int index = file.equals(mixed) ? 1 : 0; + CompoundTag chunk = readRegionChunk(file, index); + assertEquals(1456, chunk.getInt("DataVersion")); + assertEquals("example:legacy_block", + Legacy112WorldMigrator.stateAtForTest(chunk, 0, 0).getString("Name")); + assertTrue(Files.exists(world.resolve("basemetals-1.12-region-backup").resolve(world.relativize(file)))); + } + assertEquals(37, experience(world.resolve("level.dat"))); + assertArrayEquals(externalBefore, Files.readAllBytes(external)); + assertArrayEquals(externalBefore, Files.readAllBytes( + world.resolve("basemetals-1.12-region-backup/region/c.0.0.mcc"))); + assertArrayEquals(mixedBefore, Files.readAllBytes( + world.resolve("basemetals-1.12-region-backup/region/r.0.0.mca"))); + assertTrue(nativeAccepts(mixed, false)); + + byte[] mixedAfter = Files.readAllBytes(mixed); + byte[] laterAfter = Files.readAllBytes(later); + runEntry(world); + assertArrayEquals(mixedAfter, Files.readAllBytes(mixed)); + assertArrayEquals(laterAfter, Files.readAllBytes(later)); + } + + private static void appendLegacyChunk(Path file, int index, int chunkX) throws Exception { + CompoundTag chunk = new CompoundTag(); + chunk.putInt("DataVersion", 1343); + CompoundTag level = new CompoundTag(); + level.putInt("xPos", chunkX); + level.putInt("zPos", 0); + level.putBoolean("TerrainPopulated", true); + level.putBoolean("LightPopulated", true); + level.put("Entities", new ListTag()); + level.put("TileEntities", new ListTag()); + level.putByteArray("Biomes", new byte[256]); + level.putIntArray("HeightMap", new int[256]); + CompoundTag section = new CompoundTag(); + section.putByte("Y", (byte) 0); + byte[] blocks = new byte[4096]; + blocks[0] = (byte) 200; + section.putByteArray("Blocks", blocks); + section.putByteArray("Data", new byte[2048]); + ListTag sections = new ListTag(); + sections.add(section); + level.put("Sections", sections); + chunk.put("Level", level); + + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(new DeflaterOutputStream(compressed))) { + NbtIo.write(chunk, output); + } + byte[] payload = compressed.toByteArray(); + byte[] original = Files.readAllBytes(file); + int offset = (original.length + 4095) / 4096; + int sectors = (payload.length + 5 + 4095) / 4096; + ByteBuffer result = ByteBuffer.wrap(Arrays.copyOf(original, (offset + sectors) * 4096)); + result.putInt(index * 4, (offset << 8) | sectors); + result.position(offset * 4096); + result.putInt(payload.length + 1); + result.put((byte) 2); + result.put(payload); + Files.write(file, result.array()); + } + + private static CompoundTag readRegionChunk(Path file, int index) throws Exception { + byte[] contents = Files.readAllBytes(file); + ByteBuffer header = ByteBuffer.wrap(contents); + int offset = (header.getInt(index * 4) >>> 8) * 4096; + int length = header.getInt(offset); + assertEquals(2, contents[offset + 4]); + try (DataInputStream input = new DataInputStream(new InflaterInputStream( + new ByteArrayInputStream(contents, offset + 5, length - 1)))) { + return (CompoundTag) readNbt(input); + } + } + + private static void writeRegistry(Path file) throws IOException { + try (DataOutputStream output = new DataOutputStream(new GZIPOutputStream(Files.newOutputStream(file)))) { + output.writeByte(10); + output.writeUTF(""); + output.writeByte(10); + output.writeUTF("Data"); + integer(output, "DataVersion", 2730); + output.writeByte(0); + for (String name : Arrays.asList("FML", "Registries", "minecraft:blocks")) { + output.writeByte(10); + output.writeUTF(name); + } + output.writeByte(9); + output.writeUTF("ids"); + output.writeByte(10); + output.writeInt(3); + integer(output, "V", 0); + output.writeByte(8); + output.writeUTF("K"); + output.writeUTF("minecraft:air"); + output.writeByte(0); + integer(output, "V", 300); + output.writeByte(8); + output.writeUTF("K"); + output.writeUTF("basemetals:copper_ore"); + output.writeByte(0); + integer(output, "V", 200); + output.writeByte(8); + output.writeUTF("K"); + output.writeUTF("example:legacy_block"); + output.writeByte(0); + for (int end = 0; end < 4; end++) output.writeByte(0); + } + } + + private static void runEntry(Path world) throws IOException { + Legacy112WorldMigrator.migrateIfNeeded(world); + } + + private static com.google.gson.JsonObject versionInformation() { + try (InputStream input = LegacyMigrationSafetyTest.class.getResourceAsStream("/version.json"); + Reader reader = new InputStreamReader(input, java.nio.charset.StandardCharsets.UTF_8)) { + return com.google.gson.JsonParser.parseReader(reader).getAsJsonObject(); + } catch (IOException failure) { + throw new IllegalStateException(failure); + } + } + + private static String minecraftVersion() { + return versionInformation().get("id").getAsString(); + } + + private static int dataVersion() { + return versionInformation().get("world_version").getAsInt(); + } + + private Path world(String name, boolean player) throws Exception { + Path world = Files.createTempDirectory(temporaryWorld, name + "-"); + writeLevel(world.resolve("level.dat"), DATA_VERSION, player, 37); + return world; + } + + private static void writeLevel(Path file, int version, boolean player, int xp) throws IOException { + try (DataOutputStream output = new DataOutputStream(new GZIPOutputStream(Files.newOutputStream(file)))) { + output.writeByte(10); + output.writeUTF(""); + output.writeByte(10); + output.writeUTF("Data"); + integer(output, "DataVersion", version); + if (player) { + output.writeByte(10); + output.writeUTF("Player"); + integer(output, "XpLevel", xp); + for (String name : Arrays.asList("Inventory", "EnderItems")) { + output.writeByte(9); + output.writeUTF(name); + output.writeByte(10); + output.writeInt(0); + } + output.writeByte(0); + } + output.writeByte(0); + output.writeByte(0); + } + } + + private static void integer(DataOutputStream output, String key, int value) throws IOException { + output.writeByte(3); + output.writeUTF(key); + output.writeInt(value); + } + + private static int experience(Path file) throws IOException { + try (DataInputStream input = new DataInputStream(new GZIPInputStream(Files.newInputStream(file)))) { + return NbtIo.read(input).getCompound("Data").getCompound("Player").getInt("XpLevel"); + } + } + + private static CompoundTag readNbt(DataInputStream input) throws IOException { + return NbtIo.read(input); + } + + private static Path region(Path world, boolean external, boolean lz4) throws Exception { + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + OutputStream compressor = new DeflaterOutputStream(compressed); + if (lz4) { + RegionFileVersion version = (RegionFileVersion) RegionFileVersion.class.getField("VERSION_LZ4").get(null); + compressor = version.wrap(compressed); + } + try (DataOutputStream output = new DataOutputStream(compressor)) { + output.writeByte(10); + output.writeUTF(""); + integer(output, "DataVersion", DATA_VERSION); + integer(output, "xPos", 0); + integer(output, "zPos", 0); + output.writeByte(0); + } + byte[] payload = external ? new byte[0] : compressed.toByteArray(); + int sectors = (payload.length + 5 + 4095) / 4096; + ByteBuffer buffer = ByteBuffer.allocate(8192 + sectors * 4096); + buffer.putInt((2 << 8) | sectors); + buffer.position(8192); + buffer.putInt(payload.length + 1); + buffer.put((byte) ((lz4 ? 4 : 2) | (external ? 128 : 0))); + buffer.put(payload); + Path file = world.resolve("region/r.0.0.mca"); + Files.createDirectories(file.getParent()); + Files.write(file, buffer.array()); + if (external) Files.write(file.getParent().resolve("c.0.0.mcc"), compressed.toByteArray()); + return file; + } + + private static boolean nativeAccepts(Path file, boolean empty) throws IOException { + try (RegionFile region = new RegionFile(new RegionStorageInfo("Base Metals test", Level.OVERWORLD, "chunk"), + file, file.getParent(), false); + DataInputStream input = region.getChunkDataInputStream(new ChunkPos(0, 0))) { + if (empty) { + assertNull(input); + return true; + } + + assertNotNull(input); + assertEquals(DATA_VERSION, NbtIo.read(input).getInt("DataVersion")); + return true; + } + } +}