diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3700574e6..c24d4832d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,10 +46,7 @@ jobs: - name: Build with Gradle env: BUILD_NUMBER: ${{ github.run_number }} - run: ./gradlew assemble - - - name: Run tests - run: ./gradlew check + run: ./gradlew clean build - name: Publish Test Report uses: mikepenz/action-junit-report@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d9b5f6e..d3054ade3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,16 @@ -### Base Metals 4.0.0.121012 (in development) +### Base Metals 4.0.1.121012 + +- Let compatible mods join the legacy world conversion before Minecraft fixes old chunk and item data. +- Keep basePower, baseSteam and baseElectric machine state, inventories and equipment when upgrading old Advantage worlds. +- Skip legacy conversion for modern worlds and keep backups and source registry snapshots for genuine old saves. +- Add native NeoForge regression tests for the shared migration hook and modern-world safety checks. +- Keep oversized external chunks writable during conversion and leave original region backups untouched. +- Recover player data after an interrupted startup, and run companion fixes for dedicated-server players too. +- Keep host recovery available if region conversion finishes before the first modern world save. +- End legacy terrain protection after the current world has upgraded, while retaining its old registry map. +- Keep ordinary CI builds independent of release tags and support resource tests started from an IDE. + +### Base Metals 4.0.0.121012 - Port Base Metals to Minecraft 1.21.1 and NeoForge. - Restore recipe-book unlocks and armour advancements in the new data-pack folders. diff --git a/README.md b/README.md index ca4b29da2..7472de7a0 100644 --- a/README.md +++ b/README.md @@ -33,22 +33,41 @@ shields, bows, crossbows, arrows, and bolts. The mod also includes Crack Hammers, scythes, metal anvils, the human detector, villager trades, advancements, and structure loot. -This is the Minecraft 1.21.1 NeoForge port, `4.0.0.121012`. It includes raw +This is the Minecraft 1.21.1 NeoForge port, `4.0.1.121012`. It includes 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 -Tables are no longer needed. +their registry IDs. OreSpawn handles ore generation. MMDLib supplies the shared +materials, crushing recipes, anvils and equipment effects used by this version. +Additional Loot Tables is not needed. ## Requirements - Minecraft `1.21.1` - NeoForge `21.1.247` or newer in the Minecraft 1.21.1 line - Java 21 +- MMDLib `2.0.1.121012+Neo` on both client and server - OreSpawn `[4.0.16.121012,5.0.0)` for Minecraft 1.21.1 NeoForge on both client and server This version builds against OreSpawn `4.0.16.121012`. Put -`BaseMetals-4.0.0.121012.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. +`basemetals-4.0.1.121012.jar`, the matching MMDLib jar and a compatible OreSpawn +jar in the `mods` directory. Do not carry older Forge or 1.12 MMDLib jars +forward. Additional Loot Tables is not required. Mineralogy is optional. + +### Upgrading Existing Worlds + +Back up the whole save before upgrading. This patch adds a shared conversion +hook so basePower and its expansions can preserve old Advantage machine data +before Minecraft converts legacy chunks and items. Existing Base Metals registry +names stay unchanged. Modern saves bypass the old-world conversion path. + +The converter keeps original regions and player files under +`basemetals-1.12-region-backup`. Before the loader rewrites old single-player +data, it saves a player snapshot there so an interrupted first load can resume. +The snapshot is not reapplied after the first modern world save. Finishing +region conversion alone does not discard recovery before that save. Keep the +backups with the world until you have checked +the upgrade. Large external chunk files are handled by Minecraft's own region +writer. The retained registry map does not keep world-generation protection +enabled on an already-upgraded world. ## Materials and gameplay @@ -195,7 +214,11 @@ the generated resources with the source changes. Ore models use KiriCattus' transparent overlays over vanilla textures, so resource packs can replace the surrounding stone, netherrack or end stone. -The port regression checks are separate from the normal build. Run them with: +`./gradlew check` runs the native resource and migration regression tests as part +of the normal build. These include interrupted upgrades, dedicated-server +players, large external chunks and unchanged backups. + +The additional port GameTests can be run with: ```text ./gradlew -I tools/verification/port-checks.init.gradle portTest runPortGameTestServer diff --git a/build.gradle b/build.gradle index a4588374a..2b33819fd 100644 --- a/build.gradle +++ b/build.gradle @@ -165,6 +165,7 @@ var generateModMetadata = tasks.register("generateModMetadata", ProcessResources sourceSets.main.resources.srcDir generateModMetadata // To avoid having to run "generateModMetadata" manually, make it run on every project reload neoForge.ideSyncTask generateModMetadata +apply from: 'gradle/advantage-migration-tests.gradle' tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' diff --git a/gradle.properties b/gradle.properties index b70e3d2e5..7545c102f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Project -mod_version = 4.0.0.121012 +mod_version = 4.0.1.121012 mod_name = MMD Base Metals mod_id=basemetals mod_archive_name = basemetals diff --git a/gradle/advantage-migration-tests.gradle b/gradle/advantage-migration-tests.gradle new file mode 100644 index 000000000..f24ce757a --- /dev/null +++ b/gradle/advantage-migration-tests.gradle @@ -0,0 +1,19 @@ +// The migration extension is qualified in the native NeoForge test runtime. +sourceSets.test.java.srcDir 'src/verificationTest/java' +neoForge.unitTest { + enable() + testedMod = neoForge.mods.basemetals +} +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.4' +} +tasks.named('test', Test) { + useJUnitPlatform() + maxParallelForks = 1 + maxHeapSize = '2G' + jvmArgs '-XX:ActiveProcessorCount=4' + workingDir projectDir + systemProperty 'basemetals.test.projectDir', projectDir.absolutePath + testLogging { events 'failed', 'skipped'; exceptionFormat = 'full' } +} diff --git a/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java b/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java new file mode 100644 index 000000000..d49ffd7b4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java @@ -0,0 +1,33 @@ +package zone.moddev.mc.basemetals.api.migration; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.state.BlockState; +import zone.moddev.mc.basemetals.migration.Legacy112WorldMigrator; + +/** + * Companion callbacks run before vanilla consumes legacy mod data. Register once + * during mod setup. Callbacks run serially during startup and must tolerate a + * retry after an interrupted upgrade. Do not access live worlds from a callback. + */ +public interface Legacy112MigrationExtension { + enum Payload { CHUNK, PLAYER } + + /** Mutate legacy data before snapshotting; report whether anything changed. */ + default boolean prepareData(CompoundTag tag, int dataVersion, Payload payload) { return false; } + + default void beforeChunkFlattening(CompoundTag chunk) { } + + /** + * Called for the embedded single-player host and legacy playerdata files. + * Result uses the 1.20.1 item schema; component conversion runs afterwards. + */ + default void afterPlayerFlattening(CompoundTag preparedSource, CompoundTag result) { } + + /** Return null for an unowned ID; never return air for an owned legacy block. */ + default BlockState decodeBlockState(ResourceLocation legacyId, int metadata) { return null; } + + static void register(ResourceLocation id, Legacy112MigrationExtension extension) { + Legacy112WorldMigrator.registerExtension(id, extension); + } +} diff --git a/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java b/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java index d0fe60cd9..9d8341727 100644 --- a/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java +++ b/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java @@ -1,12 +1,9 @@ package zone.moddev.mc.basemetals.migration; -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.OutputStream; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; @@ -16,20 +13,20 @@ import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; -import java.util.zip.GZIPInputStream; -import java.util.zip.InflaterInputStream; -import java.util.zip.DeflaterOutputStream; +import java.util.concurrent.ConcurrentSkipListMap; import zone.moddev.mc.basemetals.BaseMetals; import zone.moddev.mc.basemetals.MissingMappings; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension.Payload; import zone.moddev.mc.lib.common.runtime.MaterialAnvilBlock; import zone.moddev.mc.lib.common.runtime.CompatibilityDoubleSlabBlock; import zone.moddev.mc.basemetals.content.ModContent; @@ -39,7 +36,6 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; -import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; import net.minecraft.nbt.LongArrayTag; @@ -67,7 +63,11 @@ 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.core.component.DataComponents; +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.minecraft.core.registries.BuiltInRegistries; /** * Runs Mojang's complete legacy-to-flattening chunk data fixes, then restores the @@ -78,18 +78,119 @@ public final class Legacy112WorldMigrator { private static final int LAST_SUPPORTED_LEGACY_DATA_VERSION = 1_343; private static final int PALETTED_PRE_PROTOCHUNK_DATA_VERSION = 1_456; + private static final int NAMESPACED_BLOCK_ENTITY_DATA_VERSION = 704; + private static final int PRE_COMPONENT_PLAYER_DATA_VERSION = 3_465; private static final int REGION_HEADER_BYTES = 8_192; private static final int SECTOR_BYTES = 4_096; private static final String MARKER = "BASEMETALS_1_12_BLOCK_MIGRATION_COMPLETE.txt"; private static final String BACKUP_DIRECTORY = "basemetals-1.12-region-backup"; + private static final String PLAYER_SOURCE = "player-migration-source.dat"; + private static final Map EXTENSIONS = new ConcurrentSkipListMap<>(); + private static Path preparedWorld; + private static CompoundTag preparedPlayer; + private static int preparedVersion; + private static CompoundTag preparedIntegratedPlayer; private Legacy112WorldMigrator() {} - public static boolean migrateIfNeeded(Path worldRoot) throws IOException { - Path root = worldRoot.toAbsolutePath().normalize(); - if (Files.isRegularFile(root.resolve(MARKER))) return false; + static synchronized void beginWorldLoad() { + preparedWorld = null; + preparedPlayer = null; + preparedVersion = 0; + preparedIntegratedPlayer = null; + } + + /** Keep the original player before Forge and vanilla read the old save. */ + static synchronized void prepareWorld(Path levelDat, CompoundTag root) throws IOException { + Path world = levelDat.toAbsolutePath().normalize().getParent(); + if (Files.isRegularFile(world.resolve(MARKER)) && sourceDataVersion(root) >= currentDataVersion()) return; + Path journal = world.resolve(BACKUP_DIRECTORY).resolve(PLAYER_SOURCE); + if (Files.exists(journal) && !Files.isRegularFile(journal)) + throw new IOException("Pending legacy player snapshot is not a file: " + journal); + if (isLegacyLevel(root)) { + preparedWorld = world; + preparedVersion = sourceDataVersion(root); + CompoundTag data = root.getCompound("Data"); + preparedPlayer = data.contains("Player", Tag.TAG_COMPOUND) ? data.getCompound("Player").copy() : null; + // Persist before the loader can rewrite level.dat. Never replace the + // first snapshot with an already-fixed player after an interrupted load. + if (preparedPlayer != null && !Files.exists(journal)) { + CompoundTag saved = new CompoundTag(); + saved.putInt("DataVersion", preparedVersion); + saved.put("Player", preparedPlayer.copy()); + Files.createDirectories(journal.getParent()); + writeCompressedAtomically(journal, saved); + } + } + if (Files.isRegularFile(journal)) { + CompoundTag saved = NbtIo.readCompressed(journal, NbtAccounter.unlimitedHeap()); + if (!saved.contains("Player", Tag.TAG_COMPOUND) + || !saved.contains("DataVersion", Tag.TAG_ANY_NUMERIC) + || saved.getInt("DataVersion") > LAST_SUPPORTED_LEGACY_DATA_VERSION) { + throw new IOException("Invalid pending legacy player snapshot: " + journal); + } + preparedWorld = world; + preparedVersion = saved.getInt("DataVersion"); + preparedPlayer = saved.getCompound("Player").copy(); + } + } + + private static boolean isLegacyLevel(CompoundTag root) { + return root.contains("Data", Tag.TAG_COMPOUND) + && sourceDataVersion(root) <= LAST_SUPPORTED_LEGACY_DATA_VERSION; + } + + static int sourceDataVersion(CompoundTag root) { + CompoundTag data = root.getCompound("Data"); + if (data.contains("DataVersion", Tag.TAG_ANY_NUMERIC)) return data.getInt("DataVersion"); + if (data.getCompound("Version").contains("Id", Tag.TAG_ANY_NUMERIC)) + return data.getCompound("Version").getInt("Id"); + // Very old saves have no version tag. Known modern level metadata must + // not opt into the packaged numeric-ID fallback just because a tag is missing. + return data.contains("WorldGenSettings", Tag.TAG_COMPOUND) ? Integer.MAX_VALUE : 0; + } + + private static boolean hasLegacySource(Path root) throws IOException { + if (root.equals(preparedWorld) || isLegacyLevel(NbtIo.readCompressed(root.resolve("level.dat"), NbtAccounter.unlimitedHeap()))) { + 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, NbtAccounter.unlimitedHeap()); + return isLegacyLevel(source) || oldBlockIds(source).values().stream() + .map(Legacy112WorldMigrator::normalizeLegacyRegistryName) + .anyMatch(id -> id.startsWith(BaseMetals.MOD_ID + ":") || id.startsWith("mmdlib:")); + } + + public static void registerExtension(ResourceLocation id, Legacy112MigrationExtension extension) { + if (EXTENSIONS.putIfAbsent(Objects.requireNonNull(id), Objects.requireNonNull(extension)) != null) { + throw new IllegalArgumentException("Duplicate legacy world migration extension: " + id); + } + } + + public static synchronized boolean migrateIfNeeded(Path worldRoot) throws IOException { + return migrateIfNeeded(worldRoot, List.copyOf(EXTENSIONS.values())); + } + static synchronized boolean migrateIfNeeded(Path worldRoot, List extensions) throws IOException { + Path root = worldRoot.toAbsolutePath().normalize(); if (!Files.isRegularFile(root.resolve("level.dat"))) return false; + if (Files.isRegularFile(root.resolve(MARKER))) { + // Region conversion may finish before the first modern level.dat save. + // A new process must still repair that host, without converting regions again. + if (root.equals(preparedWorld) && preparedPlayer != null && preparedIntegratedPlayer == null + && sourceDataVersion(NbtIo.readCompressed(root.resolve("level.dat"), NbtAccounter.unlimitedHeap())) + < currentDataVersion()) { + return !migrateLooseInventories(root, extensions).isEmpty(); + } + return false; + } + if (!hasLegacySource(root)) return false; + Map oldBlockIds = legacyBlockIds(root); if (oldBlockIds.values().stream().noneMatch(id -> id.startsWith(BaseMetals.MOD_ID + ":"))) return false; @@ -103,7 +204,7 @@ public static boolean migrateIfNeeded(Path worldRoot) throws IOException { int convertedItemAliases = 0; int convertedDamageValues = 0; for (Path region : regions) { - RegionConversion result = convertRegion(root, region, oldBlockIds); + RegionConversion result = convertRegion(root, region, oldBlockIds, extensions); if (result.convertedChunks() > 0) { convertedRegions++; convertedChunks += result.convertedChunks(); @@ -114,14 +215,14 @@ public static boolean migrateIfNeeded(Path worldRoot) throws IOException { convertedDamageValues += result.convertedDamageValues(); } } - ItemMigration looseItems = restoreRewrittenSingleplayerPlayer(root) - .plus(migrateLooseInventories(root)); + ItemMigration looseItems = restoreRewrittenSingleplayerPlayer(root, extensions) + .plus(migrateLooseInventories(root, extensions)); convertedBuckets += looseItems.convertedBuckets(); convertedItemAliases += looseItems.convertedItemAliases(); convertedDamageValues += looseItems.convertedDamageValues(); if (convertedChunks == 0 && convertedBuckets == 0 - && convertedItemAliases == 0 && convertedDamageValues == 0) { + && convertedItemAliases == 0 && convertedDamageValues == 0 && !looseItems.extensionChanged()) { Files.writeString(root.resolve(MARKER), "No legacy Forge numeric chunks or filled buckets required Base Metals migration." + System.lineSeparator(), @@ -131,11 +232,12 @@ public static boolean migrateIfNeeded(Path worldRoot) throws IOException { String result = "Converted " + convertedChunks + " legacy chunks in " + convertedRegions + " region files through Mojang's legacy flattening fixes. Backups: " + BACKUP_DIRECTORY - + ". Third-party states preserved by registry name at their default state: " + + ". Third-party states restored: " + externalStates + ". Legacy block entities preserved: " + preservedBlockEntities + ". Legacy filled buckets converted: " + convertedBuckets + ". Legacy item aliases forwarded: " + convertedItemAliases - + ". Mod-item damage values preserved: " + convertedDamageValues + "."; + + ". Mod-item damage values preserved: " + convertedDamageValues + "." + + (looseItems.extensionChanged() ? " Companion player data migrated." : ""); Files.writeString(root.resolve(MARKER), result + System.lineSeparator(), StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); BaseMetals.LOGGER.info(result); @@ -143,26 +245,31 @@ public static boolean migrateIfNeeded(Path worldRoot) throws IOException { } /** - * Refresh the single-player inventory from the migrated level.dat. The - * integrated server reads it before - * {@link net.neoforged.neoforge.event.server.ServerAboutToStartEvent}, so it - * would otherwise keep the old items until the next load. * The integrated server reads the root Player tag before * {@link net.neoforged.neoforge.event.server.ServerAboutToStartEvent}. Reload * that one tag from the corrected level.dat so the first upgraded * single-player session receives the same item migration as playerdata. */ - public static void refreshLoadedSingleplayerPlayer(MinecraftServer server, Path worldRoot) throws IOException { - CompoundTag loaded = server.getWorldData().getLoadedPlayerTag(); + public static synchronized void refreshLoadedSingleplayerPlayer(MinecraftServer server, Path worldRoot) throws IOException { + refreshLoadedSingleplayerPlayer(server.getWorldData().getLoadedPlayerTag(), worldRoot); + } + + static synchronized void refreshLoadedSingleplayerPlayer(CompoundTag loaded, Path worldRoot) throws IOException { if (loaded == null) return; - CompoundTag level = NbtIo.readCompressed(worldRoot.resolve("level.dat"), NbtAccounter.unlimitedHeap()); - CompoundTag data = level.getCompound("Data"); - if (!data.contains("Player", Tag.TAG_COMPOUND)) return; - int version = data.getInt("DataVersion"); - CompoundTag corrected = DataFixTypes.PLAYER.updateToCurrentVersion(DataFixers.getDataFixer(), - data.getCompound("Player"), version); + CompoundTag corrected; + if (preparedIntegratedPlayer != null && worldRoot.toAbsolutePath().normalize().equals(preparedWorld)) { + corrected = preparedIntegratedPlayer; + preparedIntegratedPlayer = null; + } else { + CompoundTag level = NbtIo.readCompressed(worldRoot.resolve("level.dat"), NbtAccounter.unlimitedHeap()); + CompoundTag data = level.getCompound("Data"); + if (!data.contains("Player", Tag.TAG_COMPOUND)) return; + corrected = DataFixTypes.PLAYER.updateToCurrentVersion(DataFixers.getDataFixer(), + data.getCompound("Player"), data.getInt("DataVersion")); + } for (String key : List.copyOf(loaded.getAllKeys())) loaded.remove(key); loaded.merge(corrected); + preparedPlayer = null; } private static Map oldBlockIds(CompoundTag level) { @@ -177,22 +284,21 @@ private static Map oldBlockIds(CompoundTag level) { } private static Map legacyBlockIds(Path root) throws IOException { - boolean legacyWorld = false; - 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, NbtAccounter.unlimitedHeap()); - int dataVersion = candidate.getCompound("Data").getInt("DataVersion"); - if (dataVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION) continue; - legacyWorld |= candidate.contains("Data", Tag.TAG_COMPOUND); + int dataVersion = sourceDataVersion(candidate); + if (dataVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION + && !fileName.equals("basemetals-legacy-registry.dat")) continue; Map ids = oldBlockIds(candidate); if (!ids.isEmpty()) return ids; } - // Use packaged IDs only when saved metadata confirms an old world. - return legacyWorld ? embeddedBlockIds() : Map.of(); + return embeddedBlockIds(); } - static Map legacyBlockIdsForTest(Path root) throws IOException { + static synchronized Map legacyBlockIdsForTest(Path root) throws IOException { + if (!Files.isRegularFile(root.resolve("level.dat")) || !hasLegacySource(root)) return Map.of(); return legacyBlockIds(root); } @@ -212,7 +318,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")) @@ -237,75 +343,110 @@ private static void ensureBackupCapacity(Path root, List regions) throws I } } - private static RegionConversion convertRegion(Path root, Path region, Map ids) throws IOException { + private static RegionConversion convertRegion(Path root, Path region, Map ids, + List extensions) throws IOException { byte[] source = Files.readAllBytes(region); + if (source.length == 0) return new RegionConversion(0, 0, 0, 0, 0, 0); if (source.length < REGION_HEADER_BYTES) throw new IOException("Invalid region header: " + region); ByteBuffer locations = ByteBuffer.wrap(source, 0, SECTOR_BYTES).order(ByteOrder.BIG_ENDIAN); - byte[] timestamps = Arrays.copyOfRange(source, SECTOR_BYTES, REGION_HEADER_BYTES); - List chunks = new ArrayList<>(); + Map updates = new LinkedHashMap<>(); int converted = 0; int externalStates = 0; int preservedBlockEntities = 0; int convertedBuckets = 0; int convertedItemAliases = 0; int convertedDamageValues = 0; - - 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); + Set externalFiles = new java.util.LinkedHashSet<>(); + String[] coordinates = region.getFileName().toString().split("\\."); + int regionX = Integer.parseInt(coordinates[1]); + int regionZ = Integer.parseInt(coordinates[2]); + + // RegionFile opens read-write and pads on close. Inspect a disposable copy + // so even a no-op scan cannot alter the source or its later backup. + Path inspection = Files.createTempDirectory("basemetals-region-"); + try { + Path copy = inspection.resolve(region.getFileName()); + Files.copy(region, copy); + 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) { + Path external = region.getParent().resolve("c." + chunkPos.x + "." + chunkPos.z + ".mcc"); + externalFiles.add(external); + Files.copy(external, inspection.resolve(external.getFileName())); + } } - 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); + try (RegionFile reader = new RegionFile(new RegionStorageInfo("Base Metals migration", Level.OVERWORLD, "chunk"), + copy, inspection, false)) { + for (int index = 0; index < 1_024; index++) { + if (locations.getInt(index * 4) == 0) continue; + ChunkPos chunkPos = new ChunkPos(regionX * 32 + (index & 31), regionZ * 32 + (index >>> 5)); + 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, NbtAccounter.unlimitedHeap()); + } + ChunkConversion conversion = convertChunk(chunk, ids, extensions); + if (!conversion.converted()) continue; + updates.put(chunkPos, conversion.chunk()); + converted++; + externalStates += conversion.externalStates(); + preservedBlockEntities += conversion.preservedBlockEntities(); + convertedBuckets += conversion.convertedBuckets(); + convertedItemAliases += conversion.convertedItemAliases(); + convertedDamageValues += conversion.convertedDamageValues(); + } } - chunks.add(new RegionChunk(index, record)); - } - if (converted == 0) return new RegionConversion(0, 0, 0, 0, 0, 0); - ensureBackupCapacity(root, List.of(region)); - 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); + if (converted == 0) return new RegionConversion(0, 0, 0, 0, 0, 0); + List originals = new ArrayList<>(externalFiles); + originals.add(region); + ensureBackupCapacity(root, originals); + 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); - try { - Files.move(temporary, region, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { - Files.move(temporary, region, StandardCopyOption.REPLACE_EXISTING); + // Let Minecraft handle compression, oversized .mcc records and their + // stub/header updates rather than imposing an inline sector limit. + try (RegionFile writer = new RegionFile(new RegionStorageInfo("Base Metals migration", Level.OVERWORLD, "chunk"), + region, region.getParent(), true)) { + for (var entry : updates.entrySet()) { + try (DataOutputStream output = writer.getChunkDataOutputStream(entry.getKey())) { + NbtIo.write(entry.getValue(), output); + } + } + } + } finally { + try (var paths = Files.walk(inspection)) { + for (Path path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) Files.deleteIfExists(path); + } } return new RegionConversion(converted, externalStates, preservedBlockEntities, convertedBuckets, convertedItemAliases, convertedDamageValues); } - private static ChunkConversion convertChunk(CompoundTag chunk, Map ids) throws IOException { + private static ChunkConversion convertChunk(CompoundTag chunk, Map ids, + List extensions) throws IOException { int sourceVersion = chunk.getInt("DataVersion"); if (sourceVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION) { return new ChunkConversion(false, 0, 0, 0, 0, 0, chunk); } ItemMigration itemMigration = convertLegacyItems(chunk); + boolean extensionChanged = prepareExtensions(extensions, chunk, sourceVersion, Payload.CHUNK); CompoundTag level = chunk.getCompound("Level"); ListTag sections = level.getList("Sections", Tag.TAG_COMPOUND); List savedBlocks = new ArrayList<>(); @@ -317,16 +458,18 @@ private static ChunkConversion convertChunk(CompoundTag chunk, Map> savedBlockEntities = snapshotLegacyBlockEntities(level, savedBlocks); - if (savedBlocks.isEmpty() && itemMigration.isEmpty()) { + if (savedBlocks.isEmpty() && itemMigration.isEmpty() && !extensionChanged) { return new ChunkConversion(false, 0, 0, 0, 0, 0, chunk); } + for (Legacy112MigrationExtension extension : extensions) extension.beforeChunkFlattening(chunk); + Map> savedBlockEntities = + snapshotLegacyBlockEntities(chunk.getCompound("Level"), savedBlocks); CompoundTag fixed = DataFixTypes.CHUNK.update(DataFixers.getDataFixer(), chunk, sourceVersion, PALETTED_PRE_PROTOCHUNK_DATA_VERSION); fixed.putInt("DataVersion", PALETTED_PRE_PROTOCHUNK_DATA_VERSION); if (!savedBlocks.isEmpty()) { - restoreSavedBlocks(fixed, savedBlocks); + restoreSavedBlocks(fixed, savedBlocks, extensions); restoreLegacyBlockEntities(fixed, savedBlocks, savedBlockEntities); } int preservedBlockEntities = savedBlockEntities.values().stream().mapToInt(List::size).sum(); @@ -335,14 +478,29 @@ private static ChunkConversion convertChunk(CompoundTag chunk, Map ids) throws IOException { - ChunkConversion conversion = convertChunk(chunk, ids); + return convertChunkForTest(chunk, ids, List.of()); + } + + static CompoundTag convertChunkForTest(CompoundTag chunk, Map ids, + List extensions) throws IOException { + ChunkConversion conversion = convertChunk(chunk, ids, extensions); if (!conversion.converted()) throw new IOException("Validation chunk did not contain a third-party block"); return conversion.chunk(); } - private static ItemMigration migrateLooseInventories(Path root) throws IOException { + private static boolean prepareExtensions(List extensions, CompoundTag tag, + int dataVersion, Payload payload) { + if (dataVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION) return false; + boolean changed = false; + for (Legacy112MigrationExtension extension : extensions) changed |= extension.prepareData(tag, dataVersion, payload); + return changed; + } + + private static ItemMigration migrateLooseInventories(Path root, List extensions) + throws IOException { List files = new ArrayList<>(); - files.add(root.resolve("level.dat")); + Path levelDat = root.resolve("level.dat"); + files.add(levelDat); Path playerData = root.resolve("playerdata"); if (Files.isDirectory(playerData)) { try (var paths = Files.list(playerData)) { @@ -358,56 +516,103 @@ private static ItemMigration migrateLooseInventories(Path root) throws IOExcepti if (!Files.isRegularFile(file)) continue; CompoundTag data = NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap()); ItemMigration changed = convertLegacyItems(data); - if (changed.isEmpty()) continue; + boolean extensionChanged = false; + if (file.equals(levelDat)) { + CompoundTag world = data.getCompound("Data"); + if (root.equals(preparedWorld) && preparedPlayer != null && sourceDataVersion(data) < currentDataVersion()) { + CompoundTag player = preparedPlayer.copy(); + ItemMigration originalItems = convertLegacyItems(player); + prepareExtensions(extensions, player, preparedVersion, Payload.PLAYER); + preparedIntegratedPlayer = flattenPreparedPlayer(player, preparedVersion, extensions).player(); + converted = converted.plus(originalItems).withExtensionChange(true); + } else if (!extensions.isEmpty() && isLegacyLevel(data) && world.contains("Player", Tag.TAG_COMPOUND)) { + CompoundTag player = world.getCompound("Player"); + int version = sourceDataVersion(data); + extensionChanged = prepareExtensions(extensions, player, version, Payload.PLAYER); + if (root.equals(preparedWorld)) { + // Integrated startup can still have legacy level.dat on disk. Keep its + // version and layout intact; refresh only the already-loaded player. + PlayerConversion fixed = flattenPreparedPlayer(player, version, extensions); + preparedIntegratedPlayer = fixed.player(); + converted = converted.withExtensionChange(fixed.extensionChanged()); + } + } + } else { + int version = data.getInt("DataVersion"); + extensionChanged = prepareExtensions(extensions, data, version, Payload.PLAYER); + if (version <= LAST_SUPPORTED_LEGACY_DATA_VERSION) { + PlayerConversion fixed = flattenPreparedPlayer(data, version, extensions); + data = fixed.player(); + data.putInt("DataVersion", currentDataVersion()); + extensionChanged = true; + } + } + if (changed.isEmpty() && !extensionChanged) continue; Path backup = root.resolve(BACKUP_DIRECTORY).resolve(root.relativize(file)); Files.createDirectories(backup.getParent()); if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES); writeCompressedAtomically(file, data); - converted = converted.plus(changed); + converted = converted.plus(changed.withExtensionChange(extensionChanged)); } return converted; } - /** - * 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. - */ - private static ItemMigration restoreRewrittenSingleplayerPlayer(Path root) throws IOException { + /** Restore the original player captured before rewriting, including crash recovery. */ + private static ItemMigration restoreRewrittenSingleplayerPlayer(Path root, + List extensions) 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; } CompoundTag current = NbtIo.readCompressed(currentFile, NbtAccounter.unlimitedHeap()); CompoundTag currentData = current.getCompound("Data"); - if (currentData.getInt("DataVersion") <= LAST_SUPPORTED_LEGACY_DATA_VERSION - || !currentData.contains("Player", Tag.TAG_COMPOUND)) { - return ItemMigration.NONE; - } - - CompoundTag legacy = NbtIo.readCompressed(legacyFile, NbtAccounter.unlimitedHeap()); - CompoundTag legacyData = legacy.getCompound("Data"); - int legacyVersion = legacyData.getInt("DataVersion"); - if (legacyVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION - || !legacyData.contains("Player", Tag.TAG_COMPOUND)) { + if (sourceDataVersion(current) < currentDataVersion()) { 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); - currentData.put("Player", fixedPlayer); + boolean extensionChanged = prepareExtensions(extensions, player, preparedVersion, Payload.PLAYER); + PlayerConversion fixed = flattenPreparedPlayer(player, preparedVersion, extensions); + boolean playerChanged = !currentData.getCompound("Player").equals(fixed.player()); + currentData.put("Player", fixed.player()); + preparedIntegratedPlayer = fixed.player().copy(); Path backup = root.resolve(BACKUP_DIRECTORY).resolve(root.relativize(currentFile)); Files.createDirectories(backup.getParent()); if (!Files.exists(backup)) Files.copy(currentFile, backup, StandardCopyOption.COPY_ATTRIBUTES); writeCompressedAtomically(currentFile, current); - return migration; + return migration.withExtensionChange(extensionChanged || fixed.extensionChanged() || playerChanged); + } + + private static PlayerConversion flattenPreparedPlayer(CompoundTag player, int version, + List extensions) { + var fixer = DataFixers.getDataFixer(); + if (extensions.isEmpty()) { + return new PlayerConversion(DataFixTypes.PLAYER.updateToCurrentVersion(fixer, player, version), false); + } + + CompoundTag preparedSource = player.copy(); + if (version < NAMESPACED_BLOCK_ENTITY_DATA_VERSION) { + player = DataFixTypes.PLAYER.update(fixer, player, version, + NAMESPACED_BLOCK_ENTITY_DATA_VERSION); + version = NAMESPACED_BLOCK_ENTITY_DATA_VERSION; + } + + // Give extensions the flattened 1.20.1 item shape so they can restore names before componentization. + CompoundTag flattened = DataFixTypes.PLAYER.update(fixer, player, version, PRE_COMPONENT_PLAYER_DATA_VERSION); + CompoundTag beforeCallbacks = flattened.copy(); + for (Legacy112MigrationExtension extension : extensions) { + extension.afterPlayerFlattening(preparedSource, flattened); + } + boolean changed = !beforeCallbacks.equals(flattened); + CompoundTag current = DataFixTypes.PLAYER.updateToCurrentVersion(fixer, flattened, PRE_COMPONENT_PLAYER_DATA_VERSION); + return new PlayerConversion(current, changed); + } + + private static int currentDataVersion() { + return net.minecraft.SharedConstants.getCurrentVersion().getDataVersion().getVersion(); } private static ItemMigration convertLegacyItems(Tag value) { @@ -433,15 +638,13 @@ private static ItemMigration convertLegacyItems(Tag value) { || oldId.getNamespace().equals("mmdlib")) && compound.contains("Count", Tag.TAG_ANY_NUMERIC)) { ResourceLocation targetId = MissingMappings.itemTargetId(oldId.getPath()); - net.minecraft.world.item.Item item = BuiltInRegistries.ITEM.get(targetId); + net.minecraft.world.item.Item item = BuiltInRegistries.ITEM.getOptional(targetId).orElse(null); if (item != null && !oldId.equals(targetId)) { compound.putString("id", targetId.toString()); convertedItemAliases++; } int damage = compound.getInt("Damage"); - if (item != null - && item.components().getOrDefault(DataComponents.MAX_DAMAGE, 0) > 0 - && damage > 0 + if (item != null && item.getDefaultInstance().isDamageableItem() && damage > 0 && compound.contains("Damage", Tag.TAG_ANY_NUMERIC)) { CompoundTag itemTag = compound.contains("tag", Tag.TAG_COMPOUND) ? compound.getCompound("tag") : new CompoundTag(); @@ -476,6 +679,9 @@ private static ItemMigration convertLegacyItems(Tag value) { private static void writeCompressedAtomically(Path file, CompoundTag data) throws IOException { Path temporary = file.resolveSibling(file.getFileName() + ".basemetals.tmp"); NbtIo.writeCompressed(data, temporary); + try (var channel = java.nio.channels.FileChannel.open(temporary, StandardOpenOption.WRITE)) { + channel.force(true); + } try { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { @@ -520,7 +726,8 @@ private static SectionSnapshot snapshotSection(CompoundTag section, Map savedBlocks) throws IOException { + private static void restoreSavedBlocks(CompoundTag chunk, List savedBlocks, + List extensions) throws IOException { Map targetSections = new HashMap<>(); ListTag sections = chunk.getCompound("Level").getList("Sections", Tag.TAG_COMPOUND); for (Tag value : sections) { @@ -552,7 +759,7 @@ private static void restoreSavedBlocks(CompoundTag chunk, List save } for (LegacyBlock legacy : entry.getValue()) { - CompoundTag state = restoredState(legacy, byPosition); + CompoundTag state = restoredState(legacy, byPosition, extensions); Integer paletteId = paletteIds.get(state); if (paletteId == null) { paletteId = palette.size(); @@ -646,15 +853,15 @@ private static int bitsForPalette(int paletteSize) { return Math.max(4, 32 - Integer.numberOfLeadingZeros(Math.max(1, paletteSize - 1))); } - private static CompoundTag restoredState(LegacyBlock legacy, Map byPosition) - throws IOException { + private static CompoundTag restoredState(LegacyBlock legacy, Map byPosition, + List extensions) throws IOException { String normalizedName = normalizeLegacyRegistryName(legacy.name()); ResourceLocation oldId = ResourceLocation.tryParse(normalizedName); if (oldId == null) throw new IOException("Invalid legacy block registry name " + legacy.name()); if (oldId.getNamespace().equals(BaseMetals.MOD_ID) || oldId.getNamespace().equals("mmdlib")) { String path = MissingMappings.blockTargetPath(oldId.getPath()); ResourceLocation targetId = ResourceLocation.fromNamespaceAndPath(BaseMetals.MOD_ID, path); - Block block = BuiltInRegistries.BLOCK.get(targetId); + Block block = BuiltInRegistries.BLOCK.getOptional(targetId).orElse(null); if (block == null) throw new IOException("No 1.18 block exists for legacy block " + legacy.name()); BlockState state = stateForLegacyMetadata(block, legacy.metadata()); if (block instanceof DoorBlock) { @@ -674,6 +881,17 @@ private static CompoundTag restoredState(LegacyBlock legacy, Map>> ((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); - DataOutputStream data = new DataOutputStream(deflater)) { - NbtIo.write(chunk, data); - } - byte[] payload = compressed.toByteArray(); - ByteBuffer record = ByteBuffer.allocate(payload.length + 5).order(ByteOrder.BIG_ENDIAN); - record.putInt(payload.length + 1); - record.put((byte) 2); - record.put(payload); - return record.array(); - } - - private static byte[] buildRegion(List chunks, byte[] timestamps) throws IOException { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - byte[] header = new byte[REGION_HEADER_BYTES]; - System.arraycopy(timestamps, 0, header, SECTOR_BYTES, SECTOR_BYTES); - int sector = 2; - List records = new ArrayList<>(); - for (RegionChunk chunk : chunks) { - int sectors = (chunk.record().length + SECTOR_BYTES - 1) / SECTOR_BYTES; - if (sectors > 255) throw new IOException("Chunk exceeds the region format sector limit"); - putInt(header, chunk.index() * 4, sector << 8 | sectors); - byte[] padded = Arrays.copyOf(chunk.record(), sectors * SECTOR_BYTES); - records.add(padded); - sector += sectors; - } - output.write(header); - for (byte[] record : records) output.write(record); - return output.toByteArray(); - } - private static int intAt(byte[] bytes, int position) { return ByteBuffer.wrap(bytes, position, 4).order(ByteOrder.BIG_ENDIAN).getInt(); } - private static void putInt(byte[] bytes, int position, int value) { - ByteBuffer.wrap(bytes, position, 4).order(ByteOrder.BIG_ENDIAN).putInt(value); - } - - private record RegionChunk(int index, byte[] record) {} private record RegionConversion(int convertedChunks, int externalStates, int preservedBlockEntities, int convertedBuckets, int convertedItemAliases, int convertedDamageValues) {} private record ChunkConversion(boolean converted, int externalStates, int preservedBlockEntities, int convertedBuckets, int convertedItemAliases, int convertedDamageValues, CompoundTag chunk) {} - private record ItemMigration(int convertedBuckets, int convertedItemAliases, int convertedDamageValues) { + private record PlayerConversion(CompoundTag player, boolean extensionChanged) {} + private record ItemMigration(int convertedBuckets, int convertedItemAliases, int convertedDamageValues, + boolean extensionChanged) { private static final ItemMigration NONE = new ItemMigration(0, 0, 0); + private ItemMigration(int convertedBuckets, int convertedItemAliases, int convertedDamageValues) { + this(convertedBuckets, convertedItemAliases, convertedDamageValues, false); + } + private boolean isEmpty() { - return convertedBuckets == 0 && convertedItemAliases == 0 && convertedDamageValues == 0; + return convertedBuckets == 0 && convertedItemAliases == 0 && convertedDamageValues == 0 && !extensionChanged; + } + + private ItemMigration withExtensionChange(boolean changed) { + return changed && !extensionChanged + ? new ItemMigration(convertedBuckets, convertedItemAliases, convertedDamageValues, true) : this; } private ItemMigration plus(ItemMigration other) { return new ItemMigration(convertedBuckets + other.convertedBuckets, convertedItemAliases + other.convertedItemAliases, - convertedDamageValues + other.convertedDamageValues); + convertedDamageValues + other.convertedDamageValues, extensionChanged || other.extensionChanged); } } private record SectionSnapshot(List blocks, int externalStates) {} 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 a6cfaf5c1..8b7f24e94 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; @@ -86,20 +87,18 @@ public final class LegacyWorldDataHook { private LegacyWorldDataHook() {} - /** Called by the coremod before Forge reads level.dat. */ + /** Called by the coremod before the loader reads level.dat. */ public static synchronized void prepareLegacyWorld(File levelDat) { + Legacy112WorldMigrator.beginWorldLoad(); legacyWorldActive = false; legacyTerrainChunks = Collections.emptyMap(); LEGACY_BASE_METALS_BLOCK_COUNTS.clear(); prepareLevelMetadata(levelDat); + if (legacyWorldActive) indexLegacyChunks(levelDat.getParentFile()); File source = legacyRegistrySource(levelDat); if (source == null) return; try (FileInputStream input = new FileInputStream(source)) { CompoundTag root = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); - if (root.getCompound("Data").getInt("DataVersion") < 2834) { - legacyWorldActive = true; - indexLegacyChunks(levelDat.getParentFile()); - } if (root.contains("FML", 10)) { prepareLegacyData(levelDat.getParentFile(), root.getCompound("FML")); } @@ -116,15 +115,33 @@ private static void prepareLevelMetadata(File levelDat) { try (FileInputStream input = new FileInputStream(levelDat)) { root = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); } + Legacy112WorldMigrator.prepareWorld(levelDat.toPath(), root); + legacyWorldActive = root.contains("Data", Tag.TAG_COMPOUND) + && Legacy112WorldMigrator.sourceDataVersion(root) < 2834; 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 { + if (!root.contains("Data", Tag.TAG_COMPOUND) || Legacy112WorldMigrator.sourceDataVersion(root) >= 2834) return; + Path sidecar = levelDat.toPath().resolveSibling(REGISTRY_SIDECAR); + if (Files.exists(sidecar)) return; + CompoundTag saved = new CompoundTag(); + CompoundTag source = new CompoundTag(); + source.putInt("DataVersion", Legacy112WorldMigrator.sourceDataVersion(root)); + 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"}) { @@ -356,7 +373,6 @@ private static synchronized void prepareLegacyData(File worldDirectory, Compound boolean first = !LEGACY_WORLD_DATA.containsKey(key); int states = installLegacyBlockStates(registries.getCompound(BLOCK_REGISTRY.toString())); if (first) LEGACY_WORLD_DATA.put(key, tag.copy()); - legacyWorldActive |= states > 0; File sidecar = new File(worldDirectory, REGISTRY_SIDECAR); if (!sidecar.isFile()) { CompoundTag saved = new CompoundTag(); @@ -367,7 +383,7 @@ private static synchronized void prepareLegacyData(File worldDirectory, Compound throw new IllegalStateException("Could not preserve the legacy registry in " + sidecar, exception); } } - int chunks = indexLegacyChunks(worldDirectory); + int chunks = legacyTerrainChunks.values().stream().mapToInt(LongSet::size).sum(); if (first && states > 0) { BaseMetals.LOGGER.info("Prepared {} legacy Base Metals states and protected {} existing chunks in '{}'", Integer.valueOf(states), Integer.valueOf(chunks), worldDirectory); @@ -657,13 +673,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, NbtAccounter.unlimitedHeap()); if ((root.contains("FML", 10) && containsLegacyBaseMetalsRegistryEntry(root)) - || (root.contains("Data", 10) && root.getCompound("Data").getInt("DataVersion") < 2834)) return candidate; + || (root.contains("Data", Tag.TAG_COMPOUND) + && Legacy112WorldMigrator.sourceDataVersion(root) < 2834)) return candidate; } catch (IOException ignored) {} } return null; diff --git a/src/verificationTest/java/zone/moddev/mc/basemetals/data/NeoForgeResourcesTest.java b/src/verificationTest/java/zone/moddev/mc/basemetals/data/NeoForgeResourcesTest.java index 88bbf747e..692f7084d 100644 --- a/src/verificationTest/java/zone/moddev/mc/basemetals/data/NeoForgeResourcesTest.java +++ b/src/verificationTest/java/zone/moddev/mc/basemetals/data/NeoForgeResourcesTest.java @@ -13,7 +13,35 @@ import com.google.gson.JsonParser; class NeoForgeResourcesTest { - private static final Path DATA = Path.of("src/generated/resources/data"); + private static final Path ROOT = projectRoot(); + private static final Path DATA = ROOT.resolve("src/generated/resources/data"); + + static Path projectRoot() { + String configured = System.getProperty("basemetals.test.projectDir"); + if (configured != null && !configured.isBlank()) return Path.of(configured); + Path working = Path.of(".").toAbsolutePath().normalize(); + for (Path candidate = working; candidate != null; candidate = candidate.getParent()) { + if (Files.isRegularFile(candidate.resolve("build.gradle")) + && Files.isDirectory(candidate.resolve("src/generated/resources/data/basemetals"))) return candidate; + } + return working; + } + + @Test + void projectRootFallsBackWhenTheIdeDoesNotSetAGradleProperty() { + String configured = System.getProperty("basemetals.test.projectDir"); + try { + System.clearProperty("basemetals.test.projectDir"); + assertEquals(ROOT.toAbsolutePath().normalize(), projectRoot()); + System.setProperty("basemetals.test.projectDir", ""); + assertEquals(ROOT.toAbsolutePath().normalize(), projectRoot()); + System.setProperty("basemetals.test.projectDir", "example-checkout"); + assertEquals(Path.of("example-checkout"), projectRoot()); + } finally { + if (configured == null) System.clearProperty("basemetals.test.projectDir"); + else System.setProperty("basemetals.test.projectDir", configured); + } + } @Test void recipeDiscoveryUsesNativeAdvancementFilesAndStableIds() throws Exception { @@ -58,7 +86,7 @@ void chestAdditionsUseNeoForgeRegistrationAndConditions() throws Exception { @Test void everyLocaleIncludesTheCurrentPlayerText() throws Exception { - Path languages = Path.of("src/generated/resources/assets/basemetals/lang"); + Path languages = ROOT.resolve("src/generated/resources/assets/basemetals/lang"); var expected = read(languages.resolve("en_us.json")).keySet().stream() .filter(key -> key.startsWith("config.basemetals.") || key.startsWith("message.basemetals.") diff --git a/src/verificationTest/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigratorTest.java b/src/verificationTest/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigratorTest.java index b73154d14..c796db969 100644 --- a/src/verificationTest/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigratorTest.java +++ b/src/verificationTest/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigratorTest.java @@ -62,9 +62,16 @@ void oldMetadataStillEnablesPackagedRegistryFallback() throws Exception { } @Test - void rewrittenLevelStillDetectsOldMetadata() throws Exception { + void oldBackupAloneDoesNotEnableLegacyFallback() throws Exception { writeLevel("level.dat", 3955); writeLevel("level.dat_old", 1343); + assertTrue(Legacy112WorldMigrator.legacyBlockIdsForTest(world).isEmpty()); + } + + @Test + void retainedSourceSidecarStillEnablesLegacyFallback() throws Exception { + writeLevel("level.dat", 3955); + writeLevel("basemetals-legacy-registry.dat", 1343); assertFalse(Legacy112WorldMigrator.legacyBlockIdsForTest(world).isEmpty()); } -} \ No newline at end of file +} diff --git a/src/verificationTest/java/zone/moddev/mc/basemetals/migration/LegacyMigrationReviewTest.java b/src/verificationTest/java/zone/moddev/mc/basemetals/migration/LegacyMigrationReviewTest.java new file mode 100644 index 000000000..d52dbf87f --- /dev/null +++ b/src/verificationTest/java/zone/moddev/mc/basemetals/migration/LegacyMigrationReviewTest.java @@ -0,0 +1,433 @@ +package zone.moddev.mc.basemetals.migration; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import net.minecraft.SharedConstants; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.Tag; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.storage.RegionFile; +import net.minecraft.world.level.chunk.storage.RegionStorageInfo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension; + +class LegacyMigrationReviewTest { + @TempDir Path world; + + @BeforeAll static void version() { SharedConstants.tryDetectVersion(); } + @AfterEach void clearPreparation() { Legacy112WorldMigrator.beginWorldLoad(); } + + @Test void oversizedLegacyChunkStaysExternalAndKeepsItsPayload() throws Exception { + writeLevel(1343, false, 0); + CompoundTag chunk = legacyChunk(); + byte[] payload = new byte[2 * 1024 * 1024]; + new Random(545).nextBytes(payload); + chunk.getCompound("Level").putByteArray("ReviewPayload", payload); + Path region = writeChunk(chunk); + Path external = region.resolveSibling("c.0.0.mcc"); + assertTrue(Files.size(external) > 1024 * 1024, "Fixture must genuinely exceed inline storage"); + byte[] originalRegion = Files.readAllBytes(region); + byte[] originalExternal = Files.readAllBytes(external); + + assertTrue(Legacy112WorldMigrator.migrateIfNeeded(world)); + + CompoundTag converted = readChunk(region); + assertEquals(1456, converted.getInt("DataVersion")); + assertArrayEquals(payload, converted.getCompound("Level").getByteArray("ReviewPayload")); + assertTrue(Files.size(external) > 1024 * 1024); + assertArrayEquals(originalRegion, Files.readAllBytes(backup(region))); + assertArrayEquals(originalExternal, Files.readAllBytes(backup(external))); + byte[] regionAfter = Files.readAllBytes(region); + byte[] externalAfter = Files.readAllBytes(external); + assertFalse(Legacy112WorldMigrator.migrateIfNeeded(world)); + assertArrayEquals(regionAfter, Files.readAllBytes(region)); + assertArrayEquals(externalAfter, Files.readAllBytes(external)); + } + + @Test void nonAlignedConvertedRegionHasAnExactOriginalBackup() throws Exception { + writeLevel(1343, false, 0); + Path region = writeChunk(legacyChunk()); + Files.write(region, new byte[] {42}, StandardOpenOption.APPEND); + byte[] original = Files.readAllBytes(region); + + assertTrue(Legacy112WorldMigrator.migrateIfNeeded(world)); + + assertArrayEquals(original, Files.readAllBytes(backup(region))); + assertEquals(1456, readChunk(region).getInt("DataVersion")); + } + + @Test void nonAlignedUnconvertedRegionIsNeverModifiedByTheScan() throws Exception { + writeLevel(currentVersion(), false, 0); + CompoundTag source = level(1343, false, 0); + NbtIo.writeCompressed(source, world.resolve("basemetals-legacy-registry.dat")); + CompoundTag chunk = new CompoundTag(); + chunk.putInt("DataVersion", currentVersion()); + chunk.putInt("xPos", 0); + chunk.putInt("zPos", 0); + Path region = writeChunk(chunk); + Files.write(region, new byte[] {42}, StandardOpenOption.APPEND); + byte[] original = Files.readAllBytes(region); + + assertFalse(Legacy112WorldMigrator.migrateIfNeeded(world)); + + assertArrayEquals(original, Files.readAllBytes(region)); + assertFalse(Files.exists(backup(region))); + } + + @Test void playerRecoverySurvivesAProcessRestartAfterLevelRewrite() throws Exception { + String phase = System.getProperty("basemetals.reviewCrash.phase", "single"); + if (!phase.equals("single")) { + assertTrue(phase.equals("stage") || phase.equals("recover")); + Path explicit = Path.of(System.getProperty("basemetals.reviewCrash.world")).toAbsolutePath().normalize(); + if (phase.equals("stage")) { + assertFalse(Files.exists(explicit), "Process fixture must be a fresh directory"); + Files.createDirectory(explicit); + Files.writeString(explicit.resolve("review-fixture.marker"), "PR545 disposable player recovery"); + } else { + assertEquals("PR545 disposable player recovery", Files.readString(explicit.resolve("review-fixture.marker"))); + assertNotEquals(ProcessHandle.current().pid(), Long.parseLong(Files.readString(explicit.resolve("stage.pid")))); + } + world = explicit; + } + if (!phase.equals("recover")) { + writeLevel(1343, true, 7); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + writeLevel(currentVersion(), true, 37); + if (phase.equals("stage")) { + Files.writeString(world.resolve("stage.pid"), Long.toString(ProcessHandle.current().pid())); + return; + } + // The ordinary unit case also discards all preparation explicitly. + Legacy112WorldMigrator.beginWorldLoad(); + } + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + AtomicInteger callbacks = new AtomicInteger(); + + assertTrue(Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension(callbacks)))); + + CompoundTag player = read(world.resolve("level.dat")).getCompound("Data").getCompound("Player"); + assertEquals(7, player.getInt("XpLevel")); + assertEquals(1, callbacks.get()); + assertRestoredPlayer(player); + // A completed upgrade must never restore the old snapshot over later progress. + writeLevel(currentVersion(), true, 83); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + assertFalse(Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension(callbacks)))); + assertEquals(83, read(world.resolve("level.dat")).getCompound("Data").getCompound("Player").getInt("XpLevel")); + if (phase.equals("recover")) Files.writeString(world.resolve("recovered.pid"), Long.toString(ProcessHandle.current().pid())); + } + + @Test void dedicatedPlayerRetryDoesNotRepeatAnAlreadySavedCallback() throws Exception { + writeLevel(1343, false, 0); + Files.createDirectories(world.resolve("playerdata")); + for (int i = 1; i <= 2; i++) { + CompoundTag player = player(i); + player.putInt("DataVersion", 1343); + NbtIo.writeCompressed(player, world.resolve("playerdata/" + new UUID(0, i) + ".dat")); + } + AtomicInteger calls = new AtomicInteger(); + Legacy112MigrationExtension interrupted = new Legacy112MigrationExtension() { + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + if (source.getInt("XpLevel") == 2) throw new IllegalStateException("Simulated interrupted upgrade"); + extension(calls).afterPlayerFlattening(source, result); + } + }; + assertThrows(IllegalStateException.class, + () -> Legacy112WorldMigrator.migrateIfNeeded(world, List.of(interrupted))); + assertEquals(1, calls.get()); + assertFalse(Files.exists(world.resolve("BASEMETALS_1_12_BLOCK_MIGRATION_COMPLETE.txt"))); + Path first = world.resolve("playerdata/" + new UUID(0, 1) + ".dat"); + byte[] saved = Files.readAllBytes(first); + + Legacy112WorldMigrator.beginWorldLoad(); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + assertTrue(Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension(calls)))); + assertEquals(2, calls.get()); + assertArrayEquals(saved, Files.readAllBytes(first)); + } + + @Test void completedRegionsStillRecoverTheHostBeforeItsFirstModernWorldSave() throws Exception { + writeLevel(1343, true, 7); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + AtomicInteger calls = new AtomicInteger(); + assertTrue(Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension(calls)))); + CompoundTag loaded = new CompoundTag(); + Legacy112WorldMigrator.refreshLoadedSingleplayerPlayer(loaded, world); + assertRestoredPlayer(loaded); + assertEquals(1343, read(world.resolve("level.dat")).getCompound("Data").getInt("DataVersion")); + assertTrue(Files.exists(world.resolve("BASEMETALS_1_12_BLOCK_MIGRATION_COMPLETE.txt"))); + + // The region marker was saved, but the process ended before saving level.dat. + Legacy112WorldMigrator.beginWorldLoad(); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + assertTrue(Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension(calls)))); + assertFalse(Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension(calls)))); + CompoundTag restarted = new CompoundTag(); + Legacy112WorldMigrator.refreshLoadedSingleplayerPlayer(restarted, world); + assertRestoredPlayer(restarted); + assertEquals(7, restarted.getInt("XpLevel")); + assertEquals(2, calls.get()); + } + + @Test void malformedPendingSnapshotFailsClosedButACompletedUpgradeIgnoresIt() throws Exception { + writeLevel(currentVersion(), true, 37); + Path journal = world.resolve("basemetals-1.12-region-backup/player-migration-source.dat"); + Files.createDirectories(journal.getParent()); + NbtIo.writeCompressed(new CompoundTag(), journal); + byte[] before = Files.readAllBytes(world.resolve("level.dat")); + assertThrows(IllegalStateException.class, + () -> LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile())); + assertArrayEquals(before, Files.readAllBytes(world.resolve("level.dat"))); + Files.writeString(world.resolve("BASEMETALS_1_12_BLOCK_MIGRATION_COMPLETE.txt"), "complete"); + assertDoesNotThrow(() -> LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile())); + assertArrayEquals(before, Files.readAllBytes(world.resolve("level.dat"))); + } + + @Test void modernWorldGenerationMetadataWithoutDataVersionDoesNotUseNumericFallback() throws Exception { + CompoundTag level = level(currentVersion(), false, 0); + level.getCompound("Data").remove("DataVersion"); + level.getCompound("Data").put("WorldGenSettings", new CompoundTag()); + NbtIo.writeCompressed(level, world.resolve("level.dat")); + assertTrue(Legacy112WorldMigrator.legacyBlockIdsForTest(world).isEmpty()); + } + + @Test void dedicatedPlayersReceivePostFlatteningCallbacksExactlyOnce() throws Exception { + writeLevel(1343, false, 0); + Files.createDirectories(world.resolve("playerdata")); + for (int i = 1; i <= 2; i++) { + CompoundTag player = player(i); + player.putInt("DataVersion", 1343); + NbtIo.writeCompressed(player, world.resolve("playerdata/" + new UUID(0, i) + ".dat")); + } + AtomicInteger callbacks = new AtomicInteger(); + assertTrue(Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension(callbacks)))); + assertEquals(2, callbacks.get()); + for (int i = 1; i <= 2; i++) { + Path file = world.resolve("playerdata/" + new UUID(0, i) + ".dat"); + CompoundTag converted = read(file); + assertEquals(currentVersion(), converted.getInt("DataVersion")); + assertEquals(i, converted.getInt("XpLevel")); + assertRestoredPlayer(converted); + assertEquals(1343, read(backup(file)).getInt("DataVersion")); + } + assertFalse(Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension(callbacks)))); + assertEquals(2, callbacks.get()); + } + + @Test void missingDataVersionStillAllowsTheLegacyPackagedMap() throws Exception { + CompoundTag original = level(1343, false, 0); + original.getCompound("Data").remove("DataVersion"); + original.remove("FML"); + NbtIo.writeCompressed(original, world.resolve("level.dat")); + assertFalse(Legacy112WorldMigrator.legacyBlockIdsForTest(world).isEmpty()); + } + + @Test void missingDataVersionDoesNotMakeModernVersionMetadataLegacy() throws Exception { + CompoundTag original = level(currentVersion(), false, 0); + CompoundTag data = original.getCompound("Data"); + data.remove("DataVersion"); + CompoundTag version = new CompoundTag(); + version.putInt("Id", currentVersion()); + data.put("Version", version); + NbtIo.writeCompressed(original, world.resolve("level.dat")); + assertTrue(Legacy112WorldMigrator.legacyBlockIdsForTest(world).isEmpty()); + } + + @Test void missingLevelVersionStillConvertsSavedNumericBlocks() throws Exception { + CompoundTag original = level(1343, false, 0); + original.getCompound("Data").remove("DataVersion"); + NbtIo.writeCompressed(original, world.resolve("level.dat")); + Path region = writeChunk(legacyChunk()); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + + assertTrue(Legacy112WorldMigrator.migrateIfNeeded(world)); + + assertEquals("example:legacy_block", + Legacy112WorldMigrator.stateAtForTest(readChunk(region), 0, 0).getString("Name")); + } + + @Test void capturedPlayerCrossesStartupThreadsWithoutLeakingIntoAnotherWorld() throws Exception { + writeLevel(1343, true, 7); + try (var client = java.util.concurrent.Executors.newSingleThreadExecutor()) { + long clientThread = client.submit(() -> { + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + return Thread.currentThread().threadId(); + }).get(10, java.util.concurrent.TimeUnit.SECONDS); + assertNotEquals(Thread.currentThread().threadId(), clientThread); + writeLevel(currentVersion(), true, 37); + assertTrue(Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension(new AtomicInteger())))); + CompoundTag loaded = new CompoundTag(); + Legacy112WorldMigrator.refreshLoadedSingleplayerPlayer(loaded, world); + assertEquals(7, loaded.getInt("XpLevel")); + assertRestoredPlayer(loaded); + + Path other = Files.createDirectory(world.resolve("other-world")); + NbtIo.writeCompressed(level(currentVersion(), true, 91), other.resolve("level.dat")); + client.submit(() -> LegacyWorldDataHook.prepareLegacyWorld(other.resolve("level.dat").toFile())) + .get(10, java.util.concurrent.TimeUnit.SECONDS); + assertFalse(Legacy112WorldMigrator.migrateIfNeeded(other)); + assertEquals(91, read(other.resolve("level.dat")).getCompound("Data").getCompound("Player").getInt("XpLevel")); + } + } + + @Test void retainedRegistryDoesNotKeepTerrainProtectionActiveForever() throws Exception { + writeLevel(2730, false, 0); + writeChunk(legacyChunk()); + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + assertTrue(terrainActive()); + writeLevel(currentVersion(), false, 0); + + LegacyWorldDataHook.prepareLegacyWorld(world.resolve("level.dat").toFile()); + + assertFalse(terrainActive()); + assertTrue(Files.exists(world.resolve("basemetals-legacy-registry.dat"))); + } + + @Test void preparedStateUsesTheSameMonitorOnBothStartupThreads() throws Exception { + for (String name : List.of("beginWorldLoad", "prepareWorld", "migrateIfNeeded", "refreshLoadedSingleplayerPlayer")) { + var methods = java.util.Arrays.stream(Legacy112WorldMigrator.class.getDeclaredMethods()) + .filter(method -> method.getName().equals(name)).toList(); + assertFalse(methods.isEmpty()); + assertTrue(methods.stream().allMatch(method -> Modifier.isSynchronized(method.getModifiers())), name); + } + } + + private static Legacy112MigrationExtension extension(AtomicInteger calls) { + return new Legacy112MigrationExtension() { + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + calls.incrementAndGet(); + CompoundTag old = source.getList("Inventory", Tag.TAG_COMPOUND).getCompound(0); + CompoundTag item = result.getList("Inventory", Tag.TAG_COMPOUND).getCompound(0); + item.putString("id", "minecraft:iron_pickaxe"); + CompoundTag tag = item.getCompound("tag"); + tag.putInt("Damage", old.getInt("Damage")); + item.put("tag", tag); + result.putString("ReviewWitness", "post-flattening"); + } + }; + } + + private static void assertRestoredPlayer(CompoundTag player) { + assertEquals("post-flattening", player.getString("ReviewWitness")); + CompoundTag item = player.getList("Inventory", Tag.TAG_COMPOUND).getCompound(0); + assertEquals("minecraft:iron_pickaxe", item.getString("id")); + assertEquals(1, item.getInt("count")); + assertEquals(27, item.getCompound("components").getInt("minecraft:damage")); + } + + private void writeLevel(int version, boolean host, int xp) throws Exception { + NbtIo.writeCompressed(level(version, host, xp), world.resolve("level.dat")); + } + + private static CompoundTag level(int version, boolean host, int xp) { + CompoundTag root = new CompoundTag(); + CompoundTag data = new CompoundTag(); + data.putInt("DataVersion", version); + if (host) data.put("Player", player(xp)); + root.put("Data", data); + CompoundTag registry = new CompoundTag(); + ListTag ids = new ListTag(); + for (var entry : java.util.Map.of(0, "minecraft:air", 200, "example:legacy_block", 300, "basemetals:copper_ore").entrySet()) { + CompoundTag id = new CompoundTag(); + id.putInt("V", entry.getKey()); + id.putString("K", entry.getValue()); + ids.add(id); + } + registry.put("ids", ids); + CompoundTag registries = new CompoundTag(); + registries.put("minecraft:blocks", registry); + CompoundTag fml = new CompoundTag(); + fml.put("Registries", registries); + root.put("FML", fml); + return root; + } + + private static CompoundTag player(int xp) { + CompoundTag player = new CompoundTag(); + player.putInt("XpLevel", xp); + ListTag inventory = new ListTag(); + CompoundTag tool = new CompoundTag(); + tool.putString("id", "example:legacy_pickaxe"); + tool.putByte("Count", (byte) 1); + tool.putByte("Slot", (byte) 0); + tool.putShort("Damage", (short) 27); + inventory.add(tool); + player.put("Inventory", inventory); + player.put("EnderItems", new ListTag()); + return player; + } + + private static CompoundTag legacyChunk() { + CompoundTag root = new CompoundTag(); + root.putInt("DataVersion", 1343); + CompoundTag level = new CompoundTag(); + level.putInt("xPos", 0); + 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); + root.put("Level", level); + return root; + } + + private Path writeChunk(CompoundTag chunk) throws Exception { + Path path = world.resolve("region/r.0.0.mca"); + Files.createDirectories(path.getParent()); + try (RegionFile region = region(path); + DataOutputStream output = region.getChunkDataOutputStream(new ChunkPos(0, 0))) { + NbtIo.write(chunk, output); + } + return path; + } + + private static CompoundTag readChunk(Path path) throws Exception { + try (RegionFile region = region(path); + DataInputStream input = region.getChunkDataInputStream(new ChunkPos(0, 0))) { + assertNotNull(input); + return NbtIo.read(input); + } + } + + private static RegionFile region(Path path) throws Exception { + return new RegionFile(new RegionStorageInfo("Review regression", Level.OVERWORLD, "chunk"), path, path.getParent(), false); + } + + private Path backup(Path path) { return world.resolve("basemetals-1.12-region-backup").resolve(world.relativize(path)); } + private static CompoundTag read(Path path) throws Exception { return NbtIo.readCompressed(path, NbtAccounter.unlimitedHeap()); } + private static int currentVersion() { return SharedConstants.getCurrentVersion().getDataVersion().getVersion(); } + private static boolean terrainActive() throws Exception { + var field = LegacyWorldDataHook.class.getDeclaredField("legacyWorldActive"); + field.setAccessible(true); + return field.getBoolean(null); + } +} diff --git a/src/verificationTest/java/zone/moddev/mc/basemetals/migration/LegacyMigrationSafetyTest.java b/src/verificationTest/java/zone/moddev/mc/basemetals/migration/LegacyMigrationSafetyTest.java new file mode 100644 index 000000000..b1d78b908 --- /dev/null +++ b/src/verificationTest/java/zone/moddev/mc/basemetals/migration/LegacyMigrationSafetyTest.java @@ -0,0 +1,498 @@ +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 terrainProtectionEndsAfterUpgradeWhileTheRegistrySnapshotIsRetained() 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); + assertFalse(active.getBoolean(null)); + assertTrue(Files.exists(world.resolve("basemetals-legacy-registry.dat"))); + 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() { + SharedConstants.tryDetectVersion(); + return SharedConstants.getCurrentVersion().getName(); + } + + private static int dataVersion() { + SharedConstants.tryDetectVersion(); + return SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + } + + 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; + } + } +}