diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90c836c18..72ef7c4e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,9 +50,9 @@ jobs: --no-daemon --stacktrace first="$RUNNER_TEMP/basemetals-first" mkdir -p "$first" - cp build/libs/BaseMetals-3.1.0.118021.jar "$first/" - cp build/libs/BaseMetals-3.1.0.118021-sources.jar "$first/" - cp build/libs/BaseMetals-3.1.0.118021-javadoc.jar "$first/" + cp build/libs/BaseMetals-3.1.1.118021.jar "$first/" + cp build/libs/BaseMetals-3.1.1.118021-sources.jar "$first/" + cp build/libs/BaseMetals-3.1.1.118021-javadoc.jar "$first/" cp build/release/SHA256SUMS "$first/" - name: Rebuild and prove byte-identical archives @@ -60,9 +60,9 @@ jobs: ./gradlew clean assemble verifyReleaseArtifacts writeReleaseChecksums \ -PorespawnVerificationRepository="${{ steps.orespawn.outputs.repository }}" \ --no-daemon --stacktrace - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.0.118021.jar" build/libs/BaseMetals-3.1.0.118021.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.0.118021-sources.jar" build/libs/BaseMetals-3.1.0.118021-sources.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.0.118021-javadoc.jar" build/libs/BaseMetals-3.1.0.118021-javadoc.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.118021.jar" build/libs/BaseMetals-3.1.1.118021.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.118021-sources.jar" build/libs/BaseMetals-3.1.1.118021-sources.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.118021-javadoc.jar" build/libs/BaseMetals-3.1.1.118021-javadoc.jar cmp "$RUNNER_TEMP/basemetals-first/SHA256SUMS" build/release/SHA256SUMS - name: Run all required GameTests @@ -128,9 +128,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/BaseMetals-3.1.0.118021.jar - build/libs/BaseMetals-3.1.0.118021-sources.jar - build/libs/BaseMetals-3.1.0.118021-javadoc.jar + build/libs/BaseMetals-3.1.1.118021.jar + build/libs/BaseMetals-3.1.1.118021-sources.jar + build/libs/BaseMetals-3.1.1.118021-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 496c4f46c..1e65c01ad 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,9 @@ +Base Metals 3.1.1.118021 + +- Fix startup failures when Base Metals is added to an existing modern world, including worlds with 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.118021 - Bring raw metals, ore overlays and High/Low Fantasy modes to Minecraft 1.18.2, along with the fixes from the earlier ports. diff --git a/README.md b/README.md index ff1fefe1a..319abf50b 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.18.2 Forge version, `3.1.0.118021`. It brings over the +This is the Minecraft 1.18.2 Forge version, `3.1.1.118021`. It brings over the fixes and features from the earlier ports, 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.118021,5.0.0)` on both client and server This version uses OreSpawn `4.1.0.118021`. Put -`BaseMetals-3.1.0.118021.jar` and a compatible OreSpawn JAR in the `mods` +`BaseMetals-3.1.1.118021.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. @@ -179,7 +183,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.118021`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.1.118021`. 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 2d211830c..0fdc06db5 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -17,6 +17,16 @@ or the `basemetals-legacy-registry.dat` snapshot when available. If neither retains a legacy map, it uses the map captured from the public Base Metals 2.5.0-rc2.332 and MMDLib 1.0.0-rc2.36 runtime. +That fallback is only used after confirming an old source save. An old +`level.dat_old` on its own does not make a modern world a legacy world. +The single-player record is captured from the current legacy save before +Forge reads it, rather than restored from an older backup. Once the world has +been upgraded, later visits keep the player's current progress. + +The migrator uses Minecraft's own region reader, including external chunks. +Empty region files are left alone. Backups include any external chunk files +referenced by a region that needs conversion. + Before replacing any region file, `level.dat`, or playerdata file, the migrator copies the original to `basemetals-1.12-region-backup` inside the world. Region replacements are @@ -64,6 +74,11 @@ These worlds already use named block states, so they do not need the numeric ID conversion. Older wall connections are converted to the current wall states. Item aliases, tool damage, names and enchantments are retained. +The first visit records the source version in the world-local migration +sidecar. This keeps terrain protection available for chunks opened on a later +visit without relying on `level.dat_old`. The sidecar does not store a player +record. + Older custom dimensions using Minecraft's removed `vanilla_layered` biome source are converted to its 1.18 Overworld replacement. Their dimension IDs, seeds and noise settings are retained, including the large-biome setting. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 41188b600..a92b70a79 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -1,6 +1,6 @@ # Supported versions -Base Metals `3.1.0.118021` targets Minecraft 1.18.2, Forge 40.3.0 and Java 17. +Base Metals `3.1.1.118021` targets Minecraft 1.18.2, Forge 40.3.0 and Java 17. OreSpawn `4.1.0.118021` is the exact build and test dependency. Clients and servers need OreSpawn `[4.1.0.118021,5.0.0)`. @@ -10,7 +10,7 @@ Its SHA-256 is `BD8D84A96DAFB7668E95D61B9D72C62AEF3440675473E41244C182C64653356F`. Release artifacts use Maven coordinate -`zone.moddev.mc.basemetals:BaseMetals:3.1.0.118021`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.1.118021`. 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 6864f4887..be9fd14ae 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.118021 +mod_version=3.1.1.118021 # 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 c296fd9ad..634322121 100644 --- a/gradle/ide/eclipse.gradle +++ b/gradle/ide/eclipse.gradle @@ -304,7 +304,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.118021"')) { + if (metadata.contains('${') || !metadata.contains('version="3.1.1.118021"')) { throw new GradleException('Eclipse output contains unresolved or invalid Base Metals version metadata') } } diff --git a/gradle/prepare-forge-client-runtime.py b/gradle/prepare-forge-client-runtime.py index ede57c35e..cda7327f2 100644 --- a/gradle/prepare-forge-client-runtime.py +++ b/gradle/prepare-forge-client-runtime.py @@ -23,7 +23,7 @@ FORGE_INSTALLER_SHA256 = "9434c29790504dcd11ce97cb30dd8891b4b18848982357bc1b5abfa79fb95103" VERSION_MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" ASSET_OBJECT_URL = "https://resources.download.minecraft.net/{prefix}/{digest}" -USER_AGENT = "BaseMetals-release-validation/3.1.0.118021" +USER_AGENT = "BaseMetals-release-validation/3.1.1.118021" def digest_file(path: Path, algorithm: str) -> str: diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle index 1b02e3685..ec446df48 100644 --- a/gradle/release/artifacts.gradle +++ b/gradle/release/artifacts.gradle @@ -199,7 +199,7 @@ tasks.register('verifyReleaseConfiguration') { dependsOn tasks.named('verifyMavenizerCompatibilityFixture') dependsOn tasks.named('verifyJava17Toolchain') doLast { - if (configuredReleaseIdentity.modVersion != '3.1.0.118021' + if (configuredReleaseIdentity.modVersion != '3.1.1.118021' || configuredReleaseIdentity.modGroup != expectedMavenGroup || configuredReleaseIdentity.minecraftVersion != '1.18.2' || configuredReleaseIdentity.forgeVersion != '40.3.0' @@ -308,7 +308,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.118021"') + || !modsToml.contains('version="3.1.1.118021"') || !modsToml.contains('versionRange="[4.1.0.118021,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 ed51d10c7..437c76119 100644 --- a/gradle/verification/packaged-runtime.gradle +++ b/gradle/verification/packaged-runtime.gradle @@ -106,7 +106,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.118021' + if (values.getProperty('basemetals') != '3.1.1.118021' || values.getProperty('orespawn') != '4.1.0.118021' || values.getProperty('forge') != '40.3.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 cb0e1e329..b085f0320 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,8 @@ 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.minecraftforge.registries.ForgeRegistries; /** @@ -80,6 +79,9 @@ 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() {} @@ -88,6 +90,8 @@ public static void migrateIfNeeded(Path worldRoot) throws IOException { 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; @@ -139,6 +143,44 @@ public static void migrateIfNeeded(Path worldRoot) throws IOException { BaseMetals.LOGGER.info(result); } + 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(NbtIo.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 = NbtIo.readCompressed(sidecar.toFile()); + return isLegacyLevel(source) || oldBlockIds(source).values().stream() + .map(Legacy112WorldMigrator::normalizeLegacyRegistryName) + .anyMatch(id -> id.startsWith(BaseMetals.MOD_ID + ":") || id.startsWith("mmdlib:")); + } + /** * The integrated server reads the root Player tag before * {@link net.minecraftforge.event.server.ServerAboutToStartEvent}. Reload @@ -170,12 +212,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 = NbtIo.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 +245,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 +272,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 +283,64 @@ 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(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 = NbtIo.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 +422,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 +436,10 @@ private static ItemMigration restoreRewrittenSingleplayerPlayer(Path root) throw return ItemMigration.NONE; } - CompoundTag legacy = NbtIo.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 = NbtUtils.update(DataFixers.getDataFixer(), DataFixTypes.PLAYER, - player, legacyVersion); + player, preparedVersion); currentData.put("Player", fixedPlayer); Path backup = root.resolve(BACKUP_DIRECTORY).resolve(root.relativize(currentFile)); @@ -793,18 +842,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 NbtIo.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 beab8fcdb..688212bf3 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,14 +119,37 @@ private static void prepareLevelMetadata(File levelDat) { try (FileInputStream input = new FileInputStream(levelDat)) { root = NbtIo.readCompressed(input); } + Legacy112WorldMigrator.prepareWorld(levelDat.toPath(), root); + boolean changed = removeRetiredVanillaProfessionRegistry(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)) { + NbtIo.writeCompressed(saved, output); + } + } + static boolean removeRetiredVanillaProfessionRegistry(CompoundTag root) { boolean changed = false; for (String namespace : new String[] {"fml", "FML"}) { @@ -609,13 +634,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 = NbtIo.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 34b094ecd..079440df8 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.118021]" +versionRange="[3.1.1.118021]" 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 f08b91b5a..79fdedf3b 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.118021".equals(baseMetals) || !"4.1.0.118021".equals(oreSpawn)) { + if (!"3.1.1.118021".equals(baseMetals) || !"4.1.0.118021".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 f724ee94d..14e1910be 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.118021]" +versionRange="[3.1.1.118021]" 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..0db7def37 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basemetals/migration/LegacyMigrationSafetyTest.java @@ -0,0 +1,492 @@ +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.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(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; + } + } +} diff --git a/src/test/java/zone/moddev/mc/basemetals/migration/LegacyWorldSourceTest.java b/src/test/java/zone/moddev/mc/basemetals/migration/LegacyWorldSourceTest.java index 337d46862..15c5dd4a0 100644 --- a/src/test/java/zone/moddev/mc/basemetals/migration/LegacyWorldSourceTest.java +++ b/src/test/java/zone/moddev/mc/basemetals/migration/LegacyWorldSourceTest.java @@ -27,6 +27,17 @@ void usesTheUntouchedLegacyRegistryAfterForgeRewritesLevelDat() throws IOExcepti assertEquals("natura:nether_glowshroom", ids.get(1_976)); } + @Test + void prefersThePreservedRegistryEvenAfterAFlattenedWorldHasBeenUpgradedAgain() throws IOException { + writeLevel("level.dat", 2_975, 1, "minecraft:stone"); + writeLevel("level.dat_old", 512, 1_976, "minecraft:stone"); + writeLevel("basemetals-legacy-registry.dat", 2_730, 1_976, "natura:nether_glowshroom"); + + Map ids = Legacy112WorldMigrator.legacyBlockIdsForTest(temporaryWorld); + + assertEquals("natura:nether_glowshroom", ids.get(1_976)); + } + @Test void normalizesRegistryPathsThatWereLegalInMinecraft110() { assertEquals("mca:rosegoldore",