From 13d4c4e38bf74d76f2f69f307dc55d13a81e8674 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 12 Nov 2026 02:24:18 +0000 Subject: [PATCH 1/3] Keep companion data through native 26.1 world conversion --- .github/workflows/ci.yml | 18 +- CHANGELOG.txt | 5 + README.md | 6 +- docs/MIGRATION.md | 23 + docs/VERSIONS.md | 4 +- gradle.properties | 2 +- gradle/ide/eclipse.gradle | 2 +- gradle/release/artifacts.gradle | 4 +- gradle/verification/packaged-runtime.gradle | 2 +- .../Legacy112MigrationExtension.java | 32 ++ .../migration/Legacy112WorldMigrator.java | 138 +++++- .../migration/LegacyWorldDataHook.java | 2 + .../mixin/SimpleRegionStorageMixin.java | 5 +- .../packaged-resources/META-INF/mods.toml | 2 +- .../basemetals/smoke/PackagedServerSmoke.java | 2 +- .../packaged-resources/META-INF/mods.toml | 2 +- .../Legacy112MigrationExtensionTest.java | 397 ++++++++++++++++++ .../migration/NativeMigrationHooksTest.java | 39 ++ 18 files changed, 638 insertions(+), 47 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java create mode 100644 src/test/java/zone/moddev/mc/basemetals/migration/Legacy112MigrationExtensionTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ce6057e2..2414319da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,9 +59,9 @@ jobs: --no-daemon --stacktrace first="$RUNNER_TEMP/basemetals-first" mkdir -p "$first" - cp build/libs/BaseMetals-3.1.1.2601021.jar "$first/" - cp build/libs/BaseMetals-3.1.1.2601021-sources.jar "$first/" - cp build/libs/BaseMetals-3.1.1.2601021-javadoc.jar "$first/" + cp build/libs/BaseMetals-3.1.2.2601021.jar "$first/" + cp build/libs/BaseMetals-3.1.2.2601021-sources.jar "$first/" + cp build/libs/BaseMetals-3.1.2.2601021-javadoc.jar "$first/" cp build/release/SHA256SUMS "$first/" - name: Rebuild and prove byte-identical archives @@ -69,9 +69,9 @@ jobs: ./gradlew clean assemble verifyReleaseArtifacts writeReleaseChecksums \ -PorespawnVerificationRepository="${{ steps.orespawn.outputs.repository }}" \ --no-daemon --stacktrace - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.2601021.jar" build/libs/BaseMetals-3.1.1.2601021.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.2601021-sources.jar" build/libs/BaseMetals-3.1.1.2601021-sources.jar - cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.1.2601021-javadoc.jar" build/libs/BaseMetals-3.1.1.2601021-javadoc.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.2.2601021.jar" build/libs/BaseMetals-3.1.2.2601021.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.2.2601021-sources.jar" build/libs/BaseMetals-3.1.2.2601021-sources.jar + cmp "$RUNNER_TEMP/basemetals-first/BaseMetals-3.1.2.2601021-javadoc.jar" build/libs/BaseMetals-3.1.2.2601021-javadoc.jar cmp "$RUNNER_TEMP/basemetals-first/SHA256SUMS" build/release/SHA256SUMS - name: Run all required GameTests @@ -139,9 +139,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/BaseMetals-3.1.1.2601021.jar - build/libs/BaseMetals-3.1.1.2601021-sources.jar - build/libs/BaseMetals-3.1.1.2601021-javadoc.jar + build/libs/BaseMetals-3.1.2.2601021.jar + build/libs/BaseMetals-3.1.2.2601021-sources.jar + build/libs/BaseMetals-3.1.2.2601021-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 3c097cafe..c669fa506 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,8 @@ +Base Metals 3.1.2.2601021 +- Let companion mods preserve legacy machine directions, inventories and item wear before conversion. +- Keep companion chunk return callbacks running in either transformer order. +- Preserve host player data before native 26.1 world-file relocation without replacing existing player files. + Base Metals 3.1.1.2601021 - Port the released 1.21.11 version to Minecraft 26.1.2, Forge 64 and Java 25. diff --git a/README.md b/README.md index d3c06cd46..fa2044d9f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ shields, bows, crossbows, arrows, and bolts. The mod also includes Crack Hammers, scythes, metal anvils, the human detector, villager trades, advancements, and structure loot. -This is the Minecraft 26.1.2 Forge version, `3.1.1.2601021`. It keeps the +This is the Minecraft 26.1.2 Forge version, `3.1.2.2601021`. It keeps the fixes and features from the released 1.21.11 version, including raw metals, ore texture overlays and High/Low Fantasy modes. Existing Base Metals items and blocks keep their registry IDs. OreSpawn handles ore generation; MMDLib and @@ -28,7 +28,7 @@ Additional Loot Tables are no longer needed. The build uses Forge `64.0.9` and is also tested on `64.1.5`. This version uses OreSpawn `4.1.0.2601021`. Put -`BaseMetals-3.1.1.2601021.jar` and a compatible OreSpawn JAR in the `mods` +`BaseMetals-3.1.2.2601021.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. @@ -201,7 +201,7 @@ early splash window. Minecraft still opens its game window and runs the full world check. Ordinary client launches keep the normal splash screen. Release artifacts use Maven coordinate -`zone.moddev.mc.basemetals:BaseMetals:3.1.1.2601021`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.2.2601021`. Report defects and compatibility problems through the [Base Metals issue tracker](https://github.com/MinecraftModDevelopmentMods/BaseMetals/issues). diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 09d110c5a..7d3832ef3 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -151,3 +151,26 @@ old files does not activate this step. If an older single-player save has its player only in `level.dat`, Base Metals preserves a separate player file before Minecraft converts the folders. An existing player file always wins; a stale backup cannot restore an older player. + +## Companion migration extensions + +Companion mods can register a common-side +`zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension` with a +unique `Identifier` during common setup, before a world opens. Registrations +run in identifier order; duplicate identifiers fail rather than replace an owner. + +`prepareData` receives `Payload.CHUNK` or `Payload.PLAYER` before legacy +flattening. `beforeChunkFlattening` runs before the block-entity snapshot, and +`decodeBlockState` can restore owned block properties from raw metadata. +Return `null` for an unowned block; air or multiple claims fail conversion. + +When a legacy host has no existing player file, `afterPlayerFlattening` +receives its prepared source and the mutable schema-3465 player, before item +componentization. The resulting native player seeds a separate disk file before +FileFixerUpper moves it. The legacy level retains its source schema. Detached +player files receive preparation only and remain under native player-loading +authority. Existing player files in either layout are never replaced by the host. + +Chunk finalization edits the returned tag in place and does not cancel other +mods' return callbacks. Extensions must preserve unowned data, register on both +physical sides, and avoid client-only classes in these common callbacks. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 15887a0e4..6ef3ebcda 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -1,6 +1,6 @@ # Supported versions -Base Metals `3.1.1.2601021` targets Minecraft 26.1.2, Forge 64.0.9 and Java 25. +Base Metals `3.1.2.2601021` targets Minecraft 26.1.2, Forge 64.0.9 and Java 25. Forge 64.1.5 is the additional compatibility target. OreSpawn `4.1.0.2601021` is the exact build and test dependency. Clients and servers need OreSpawn `[4.1.0.2601021,5.0.0)`. @@ -11,7 +11,7 @@ Its SHA-256 is `6D5CE2017C3314968083D140309E2F1A54BC239AC65C48944C995E9ECF937948`. Release artifacts use Maven coordinate -`zone.moddev.mc.basemetals:BaseMetals:3.1.1.2601021`. +`zone.moddev.mc.basemetals:BaseMetals:3.1.2.2601021`. The provider remains schema 3, revision 1. OreSpawn 4.1 is needed for the configuration-screen API; the provider's eleven rule IDs and 1.18 distributions diff --git a/gradle.properties b/gradle.properties index e905e3fba..fabaaaa20 100644 --- a/gradle.properties +++ b/gradle.properties @@ -67,7 +67,7 @@ mod_name=Base Metals # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-2.1 # The mod version. See https://semver.org/ -mod_version=3.1.1.2601021 +mod_version=3.1.2.2601021 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle index fe1025132..2ba4bba11 100644 --- a/gradle/ide/eclipse.gradle +++ b/gradle/ide/eclipse.gradle @@ -334,7 +334,7 @@ def verifyEclipseProductionClasspath = tasks.register('verifyEclipseProductionCl } } String metadata = new File(projectDir, 'bin/main/META-INF/mods.toml').getText('UTF-8') - if (metadata.contains('${') || !metadata.contains('version="3.1.1.2601021"')) { + if (metadata.contains('${') || !metadata.contains('version="3.1.2.2601021"')) { throw new GradleException('Eclipse output contains unresolved or invalid Base Metals version metadata') } } diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle index 7911fecab..54b2c0d71 100644 --- a/gradle/release/artifacts.gradle +++ b/gradle/release/artifacts.gradle @@ -202,7 +202,7 @@ tasks.register('verifyReleaseConfiguration') { dependsOn tasks.named('verifyMavenizerCompatibilityFixture') dependsOn tasks.named('verifyJava25Toolchain') doLast { - if (configuredReleaseIdentity.modVersion != '3.1.1.2601021' + if (configuredReleaseIdentity.modVersion != '3.1.2.2601021' || configuredReleaseIdentity.modGroup != expectedMavenGroup || configuredReleaseIdentity.minecraftVersion != '26.1.2' || configuredReleaseIdentity.forgeVersion != '64.0.9' @@ -326,7 +326,7 @@ tasks.register('verifyReleaseArtifacts') { } String modsToml = zip.getInputStream(zip.getEntry('META-INF/mods.toml')).getText('UTF-8') if (modsToml.contains('${') - || !modsToml.contains('version="3.1.1.2601021"') + || !modsToml.contains('version="3.1.2.2601021"') || !modsToml.contains('versionRange="[4.1.0.2601021,5.0.0)"')) { throw new GradleException('Release mods.toml contains unresolved or incompatible metadata') } diff --git a/gradle/verification/packaged-runtime.gradle b/gradle/verification/packaged-runtime.gradle index 5e353283f..1889ddab6 100644 --- a/gradle/verification/packaged-runtime.gradle +++ b/gradle/verification/packaged-runtime.gradle @@ -174,7 +174,7 @@ def packagedServerStartupSmoke = tasks.register('packagedServerStartupSmoke', Ex if (!marker.isFile()) throw new GradleException('Packaged dedicated-server completion marker is missing') Properties values = new Properties() marker.withInputStream { values.load(it) } - if (values.getProperty('basemetals') != '3.1.1.2601021' + if (values.getProperty('basemetals') != '3.1.2.2601021' || values.getProperty('orespawn') != '4.1.0.2601021' || values.getProperty('forge') != '64.0.9') { throw new GradleException("Packaged dedicated-server versions are wrong: ${values}") diff --git a/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java b/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java new file mode 100644 index 000000000..1aec911ec --- /dev/null +++ b/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.basemetals.api.migration; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.state.BlockState; +import zone.moddev.mc.basemetals.migration.Legacy112WorldMigrator; + +/** Preserves companion data before Base Metals converts a pre-flattening world. */ +public interface Legacy112MigrationExtension { + enum Payload { CHUNK, PLAYER } + + /** Mutates owned legacy data before vanilla fixes and the block-entity snapshot. */ + default boolean prepareData(CompoundTag tag, int dataVersion, Payload payload) { return false; } + + /** Runs immediately before the intermediate chunk data fix. */ + default void beforeChunkFlattening(CompoundTag chunk) { } + + /** + * Restores owned data at the 1.20.1 item schema, before componentization. + * The prepared source is read-only; result still uses historical Count/tag. + * On 26.1 this seeds a missing host file before FileFixerUpper moves it. + */ + default void afterPlayerFlattening(CompoundTag preparedSource, CompoundTag result) { } + + /** Returns a native owned state, or null for an unowned registry ID. */ + default BlockState decodeBlockState(Identifier legacyId, int metadata) { return null; } + + /** Register during common setup, before any world is opened. */ + static void register(Identifier id, Legacy112MigrationExtension extension) { + Legacy112WorldMigrator.registerExtension(id, extension); + } +} diff --git a/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java b/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java index b8b55a2d6..13ce60822 100644 --- a/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java +++ b/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java @@ -22,11 +22,15 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentSkipListMap; import java.util.zip.DeflaterOutputStream; import zone.moddev.mc.basemetals.BaseMetals; import zone.moddev.mc.basemetals.MissingMappings; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension.Payload; import zone.moddev.mc.basemetals.content.BaseMetalAnvilBlock; import zone.moddev.mc.basemetals.content.CompatibilityDoubleSlabBlock; import zone.moddev.mc.basemetals.content.ModContent; @@ -76,10 +80,13 @@ public final class Legacy112WorldMigrator { private static final int LAST_SUPPORTED_LEGACY_DATA_VERSION = 1_343; private static final int PALETTED_PRE_PROTOCHUNK_DATA_VERSION = 1_456; + private static final int NAMESPACED_BLOCK_ENTITY_DATA_VERSION = 704; + private static final int PRE_COMPONENT_PLAYER_DATA_VERSION = 3_465; private static final int REGION_HEADER_BYTES = 8_192; private static final int SECTOR_BYTES = 4_096; private static final String MARKER = "BASEMETALS_1_12_BLOCK_MIGRATION_COMPLETE.txt"; private static final String BACKUP_DIRECTORY = "basemetals-1.12-region-backup"; + private static final Map EXTENSIONS = new ConcurrentSkipListMap<>(); private static Path preparedWorld; private Legacy112WorldMigrator() {} @@ -118,7 +125,17 @@ private static boolean hasLegacySource(Path root) throws IOException { .anyMatch(id -> id.startsWith(BaseMetals.MOD_ID + ":") || id.startsWith("mmdlib:")); } + public static void registerExtension(Identifier id, Legacy112MigrationExtension extension) { + if (EXTENSIONS.putIfAbsent(Objects.requireNonNull(id), Objects.requireNonNull(extension)) != null) { + throw new IllegalArgumentException("Duplicate legacy world migration extension: " + id); + } + } + public static void migrateIfNeeded(Path worldRoot) throws IOException { + migrateIfNeeded(worldRoot, List.copyOf(EXTENSIONS.values())); + } + + static void migrateIfNeeded(Path worldRoot, List extensions) throws IOException { Path root = worldRoot.toAbsolutePath().normalize(); if (Files.isRegularFile(root.resolve(MARKER))) return; @@ -138,7 +155,7 @@ public static void migrateIfNeeded(Path worldRoot) throws IOException { int convertedItemAliases = 0; int convertedDamageValues = 0; for (Path region : regions) { - RegionConversion result = convertRegion(root, region, oldBlockIds); + RegionConversion result = convertRegion(root, region, oldBlockIds, extensions); if (result.convertedChunks() > 0) { convertedRegions++; convertedChunks += result.convertedChunks(); @@ -149,13 +166,13 @@ public static void migrateIfNeeded(Path worldRoot) throws IOException { convertedDamageValues += result.convertedDamageValues(); } } - ItemMigration looseItems = migrateLooseInventories(root); + ItemMigration looseItems = 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(), @@ -165,11 +182,12 @@ public static void migrateIfNeeded(Path worldRoot) throws IOException { String result = "Converted " + convertedChunks + " legacy chunks in " + convertedRegions + " region files through Mojang's legacy flattening fixes. Backups: " + BACKUP_DIRECTORY - + ". Third-party states preserved by registry name at their default state: " + + ". Third-party states restored: " + externalStates + ". Legacy block entities preserved: " + preservedBlockEntities + ". Legacy filled buckets converted: " + convertedBuckets + ". Legacy item aliases forwarded: " + convertedItemAliases - + ". Mod-item damage values preserved: " + convertedDamageValues + "."; + + ". Mod-item damage values preserved: " + convertedDamageValues + "." + + (looseItems.extensionChanged() ? " Companion player data migrated." : ""); Files.writeString(root.resolve(MARKER), result + System.lineSeparator(), StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); BaseMetals.LOGGER.info(result); @@ -245,7 +263,8 @@ private static void ensureBackupCapacity(Path root, List regions) throws I } } - private static RegionConversion convertRegion(Path root, Path region, Map ids) throws IOException { + private static RegionConversion convertRegion(Path root, Path region, Map ids, + List extensions) throws IOException { byte[] source = Files.readAllBytes(region); if (source.length == 0) return new RegionConversion(0, 0, 0, 0, 0, 0); if (source.length < REGION_HEADER_BYTES) throw new IOException("Invalid region header: " + region); @@ -289,7 +308,7 @@ private static RegionConversion convertRegion(Path root, Path region, Map ids) throws IOException { + private static ChunkConversion convertChunk(CompoundTag chunk, Map ids, + List extensions) throws IOException { int sourceVersion = chunk.getIntOr("DataVersion", 0); if (sourceVersion > LAST_SUPPORTED_LEGACY_DATA_VERSION) { return new ChunkConversion(false, 0, 0, 0, 0, 0, chunk); } ItemMigration itemMigration = convertLegacyItems(chunk); + boolean extensionChanged = prepareExtensions(extensions, chunk, sourceVersion, Payload.CHUNK); CompoundTag level = chunk.getCompoundOrEmpty("Level"); ListTag sections = level.getListOrEmpty("Sections"); List savedBlocks = new ArrayList<>(); @@ -347,16 +368,18 @@ private static ChunkConversion convertChunk(CompoundTag chunk, Map> savedBlockEntities = snapshotLegacyBlockEntities(level, savedBlocks); - if (savedBlocks.isEmpty() && itemMigration.isEmpty()) { + if (savedBlocks.isEmpty() && itemMigration.isEmpty() && !extensionChanged) { return new ChunkConversion(false, 0, 0, 0, 0, 0, chunk); } + for (Legacy112MigrationExtension extension : extensions) extension.beforeChunkFlattening(chunk); + Map> savedBlockEntities = + snapshotLegacyBlockEntities(chunk.getCompoundOrEmpty("Level"), savedBlocks); CompoundTag fixed = DataFixTypes.CHUNK.update(DataFixers.getDataFixer(), chunk, sourceVersion, PALETTED_PRE_PROTOCHUNK_DATA_VERSION); fixed.putInt("DataVersion", PALETTED_PRE_PROTOCHUNK_DATA_VERSION); if (!savedBlocks.isEmpty()) { - restoreSavedBlocks(fixed, savedBlocks); + restoreSavedBlocks(fixed, savedBlocks, extensions); restoreLegacyBlockEntities(fixed, savedBlocks, savedBlockEntities); } int preservedBlockEntities = savedBlockEntities.values().stream().mapToInt(List::size).sum(); @@ -365,14 +388,51 @@ 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; + } + + static CompoundTag flattenEmbeddedPlayer(CompoundTag player, int version) { + return flattenEmbeddedPlayer(player, version, List.copyOf(EXTENSIONS.values())); + } + + static CompoundTag flattenEmbeddedPlayer(CompoundTag player, int version, + List extensions) { + if (version > LAST_SUPPORTED_LEGACY_DATA_VERSION || extensions.isEmpty()) return player; + CompoundTag preparedSource = player.copy(); + var fixer = DataFixers.getDataFixer(); + 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; + } + CompoundTag flattened = DataFixTypes.PLAYER.update(fixer, player, version, PRE_COMPONENT_PLAYER_DATA_VERSION); + for (Legacy112MigrationExtension extension : extensions) { + extension.afterPlayerFlattening(preparedSource, flattened); + } + CompoundTag current = DataFixTypes.PLAYER.updateToCurrentVersion(fixer, flattened, PRE_COMPONENT_PLAYER_DATA_VERSION); + current.putInt("DataVersion", net.minecraft.SharedConstants.getCurrentVersion().dataVersion().version()); + return current; + } + + 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); for (String directory : List.of("playerdata", "players/data")) { Path playerData = root.resolve(directory); if (Files.isDirectory(playerData)) { @@ -390,12 +450,22 @@ private static ItemMigration migrateLooseInventories(Path root) throws IOExcepti if (!Files.isRegularFile(file)) continue; CompoundTag data = LegacyNbt.readCompressed(file.toFile()); ItemMigration changed = convertLegacyItems(data); - if (changed.isEmpty()) continue; + boolean extensionChanged = false; + if (file.equals(levelDat)) { + CompoundTag world = data.getCompoundOrEmpty("Data"); + if (isLegacyLevel(data) && NbtCompat.containsTag(world, "Player", Tag.TAG_COMPOUND)) { + extensionChanged = prepareExtensions(extensions, world.getCompoundOrEmpty("Player"), + world.getIntOr("DataVersion", 0), Payload.PLAYER); + } + } else { + extensionChanged = prepareExtensions(extensions, data, data.getIntOr("DataVersion", 0), Payload.PLAYER); + } + if (changed.isEmpty() && !extensionChanged) continue; Path backup = root.resolve(BACKUP_DIRECTORY).resolve(root.relativize(file)); Files.createDirectories(backup.getParent()); if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES); writeCompressedAtomically(file, data); - converted = converted.plus(changed); + converted = converted.plus(changed.withExtensionChange(extensionChanged)); } return converted; } @@ -517,7 +587,8 @@ private static SectionSnapshot snapshotSection(CompoundTag section, Map savedBlocks) throws IOException { + private static void restoreSavedBlocks(CompoundTag chunk, List savedBlocks, + List extensions) throws IOException { Map targetSections = new HashMap<>(); ListTag sections = chunk.getCompoundOrEmpty("Level").getListOrEmpty("Sections"); for (Tag value : sections) { @@ -549,7 +620,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(); @@ -643,8 +714,8 @@ private static int bitsForPalette(int paletteSize) { return Math.max(4, 32 - Integer.numberOfLeadingZeros(Math.max(1, paletteSize - 1))); } - private static CompoundTag restoredState(LegacyBlock legacy, Map byPosition) - throws IOException { + private static CompoundTag restoredState(LegacyBlock legacy, Map byPosition, + List extensions) throws IOException { String normalizedName = normalizeLegacyRegistryName(legacy.name()); Identifier oldId = Identifier.tryParse(normalizedName); if (oldId == null) throw new IOException("Invalid legacy block registry name " + legacy.name()); @@ -671,6 +742,17 @@ private static CompoundTag restoredState(LegacyBlock legacy, Map 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 4b1d74f68..2a43ce06d 100644 --- a/src/main/java/zone/moddev/mc/basemetals/migration/LegacyWorldDataHook.java +++ b/src/main/java/zone/moddev/mc/basemetals/migration/LegacyWorldDataHook.java @@ -124,6 +124,8 @@ private static void preserveEmbeddedPlayer(Path levelDat) throws IOException { Path file = world.resolve("playerdata/" + uuid + ".dat"); if (Files.exists(file) || Files.exists(world.resolve("players/data/" + uuid + ".dat"))) return; + // Only the new host file receives components; level.dat retains its source schema. + player = Legacy112WorldMigrator.flattenEmbeddedPlayer(player, sourceVersion); // The native file fixer discards an embedded player with a UUID, expecting its disk file to exist. if (!NbtCompat.containsTag(player, "DataVersion", 99)) player.putInt("DataVersion", sourceVersion); Files.createDirectories(file.getParent()); diff --git a/src/main/java/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.java b/src/main/java/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.java index d7d8e846c..c3998f0f0 100644 --- a/src/main/java/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.java +++ b/src/main/java/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.java @@ -19,9 +19,10 @@ abstract class SimpleRegionStorageMixin { } @Inject(method = "upgradeChunkTag(Lnet/minecraft/nbt/CompoundTag;ILnet/minecraft/nbt/CompoundTag;I)Lnet/minecraft/nbt/CompoundTag;", - at = @At("RETURN"), cancellable = true) + at = @At("RETURN")) private void basemetals$finishChunk(CompoundTag chunk, int fallbackVersion, CompoundTag context, int targetVersion, CallbackInfoReturnable callback) { - callback.setReturnValue(LegacyWorldDataHook.finalizeLegacyChunk(callback.getReturnValue())); + // In-place finalization must not cancel companion RETURN callbacks. + LegacyWorldDataHook.finalizeLegacyChunk(callback.getReturnValue()); } } diff --git a/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml b/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml index 53cfb688c..626786855 100644 --- a/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml +++ b/src/packagedClientSmoke/packaged-resources/META-INF/mods.toml @@ -10,7 +10,7 @@ displayName="Base Metals packaged client smoke" [[dependencies.basemetals_client_smoke]] modId="basemetals" mandatory=true -versionRange="[3.1.1.2601021]" +versionRange="[3.1.2.2601021]" ordering="AFTER" side="CLIENT" diff --git a/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java b/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java index 4da0534b4..8492c54de 100644 --- a/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java +++ b/src/packagedServerSmoke/java/zone/moddev/mc/basemetals/smoke/PackagedServerSmoke.java @@ -40,7 +40,7 @@ public PackagedServerSmoke() { public void onServerStarted(ServerStartedEvent event) { String baseMetals = version("basemetals"); String oreSpawn = version("orespawn"); - if (!"3.1.1.2601021".equals(baseMetals) || !"4.1.0.2601021".equals(oreSpawn)) { + if (!"3.1.2.2601021".equals(baseMetals) || !"4.1.0.2601021".equals(oreSpawn)) { throw new IllegalStateException("PACKAGED_SERVER_SMOKE FAIL versions basemetals=" + baseMetals + " orespawn=" + oreSpawn); } diff --git a/src/packagedServerSmoke/packaged-resources/META-INF/mods.toml b/src/packagedServerSmoke/packaged-resources/META-INF/mods.toml index 15f050e2b..31c970415 100644 --- a/src/packagedServerSmoke/packaged-resources/META-INF/mods.toml +++ b/src/packagedServerSmoke/packaged-resources/META-INF/mods.toml @@ -10,7 +10,7 @@ displayName="Base Metals packaged server smoke" [[dependencies.basemetals_server_smoke]] modId="basemetals" mandatory=true -versionRange="[3.1.1.2601021]" +versionRange="[3.1.2.2601021]" ordering="AFTER" side="SERVER" diff --git a/src/test/java/zone/moddev/mc/basemetals/migration/Legacy112MigrationExtensionTest.java b/src/test/java/zone/moddev/mc/basemetals/migration/Legacy112MigrationExtensionTest.java new file mode 100644 index 000000000..a08328c69 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basemetals/migration/Legacy112MigrationExtensionTest.java @@ -0,0 +1,397 @@ +package zone.moddev.mc.basemetals.migration; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.SharedConstants; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.world.level.block.BarrelBlock; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension; + +class Legacy112MigrationExtensionTest { + private static final Identifier EXTENSION = Identifier.parse("example:host_player_test"); + private static final UUID PLAYER = UUID.fromString("22fd001d-a32d-4c0d-b921-7f606aa48e8e"); + private static final Map BLOCKS = Map.of(0, "minecraft:air", 200, "example:machine"); + + @BeforeAll + static void gameVersion() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + assertEquals(4790, SharedConstants.getCurrentVersion().dataVersion().version()); + } + + @Test + void vanillaChestReceivesCompanionInventoryBeforeFlattening() throws Exception { + CompoundTag chunk = chunk(54, 2); + CompoundTag chest = chunk.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities").getCompoundOrEmpty(0); + List calls = new ArrayList<>(); + var extension = new Legacy112MigrationExtension() { + @Override public boolean prepareData(CompoundTag tag, int version, Payload payload) { + assertEquals(1343, version); + assertEquals(Payload.CHUNK, payload); + prepareStack(chest.getListOrEmpty("Items").getCompoundOrEmpty(0)); + calls.add("prepare"); + return true; + } + @Override public void beforeChunkFlattening(CompoundTag tag) { + assertSame(chunk, tag); + calls.add("flatten"); + } + }; + CompoundTag fixed = Legacy112WorldMigrator.convertChunkForTest(chunk, + Map.of(0, "minecraft:air", 54, "minecraft:chest"), List.of(extension)); + assertEquals(List.of("prepare", "flatten"), calls); + CompoundTag current = DataFixTypes.CHUNK.updateToCurrentVersion(DataFixers.getDataFixer(), fixed, 1456); + assertStack(current.getListOrEmpty("block_entities").getCompoundOrEmpty(0) + .getListOrEmpty("Items").getCompoundOrEmpty(0)); + } + + @Test + void directionsAndOpaqueStagedEntitiesSurviveTheSnapshot() throws Exception { + CompoundTag chunk = chunk(200, 13); + CompoundTag machine = new CompoundTag(); + machine.putString("id", "example:machine"); + machine.putInt("x", 0); + machine.putInt("y", 0); + machine.putInt("z", 0); + chunk.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities").add(machine); + var extension = new Legacy112MigrationExtension() { + @Override public void beforeChunkFlattening(CompoundTag source) { + machine.putInt("energy", 125); + CompoundTag staged = new CompoundTag(); + staged.putString("id", "example:cart"); + staged.putLong("UUIDMost", PLAYER.getMostSignificantBits()); + staged.putLong("UUIDLeast", PLAYER.getLeastSignificantBits()); + ListTag inventory = new ListTag(); + inventory.add(legacyStack()); + staged.put("Items", inventory); + machine.put("stagedEntity", staged); + } + @Override public BlockState decodeBlockState(Identifier id, int metadata) { + assertEquals("example:machine", id.toString()); + assertEquals(13, metadata); + return Blocks.BARREL.defaultBlockState().setValue(BarrelBlock.FACING, Direction.EAST); + } + }; + CompoundTag fixed = Legacy112WorldMigrator.convertChunkForTest(chunk, BLOCKS, List.of(extension)); + CompoundTag state = Legacy112WorldMigrator.stateAtForTest(fixed, 0, 0); + assertEquals("east", state.getCompoundOrEmpty("Properties").getStringOr("facing", "")); + CompoundTag saved = fixed.getCompoundOrEmpty("Level").getListOrEmpty("TileEntities").getCompoundOrEmpty(0); + assertEquals(125, saved.getIntOr("energy", 0)); + assertEquals("example:cart", saved.getCompoundOrEmpty("stagedEntity").getStringOr("id", "")); + assertEquals(PLAYER.getMostSignificantBits(), saved.getCompoundOrEmpty("stagedEntity").getLongOr("UUIDMost", 0)); + assertEquals(machine.get("stagedEntity"), saved.get("stagedEntity")); + } + + @Test + void unclaimedStatesKeepTheirIdentityAndAirOrAmbiguousClaimsFail() throws Exception { + CompoundTag fixed = Legacy112WorldMigrator.convertChunkForTest(chunk(200, 13), BLOCKS, List.of()); + assertEquals("example:machine", Legacy112WorldMigrator.stateAtForTest(fixed, 0, 0).getStringOr("Name", "")); + var air = decoder(Blocks.AIR.defaultBlockState()); + var stone = decoder(Blocks.STONE.defaultBlockState()); + assertThrows(IOException.class, () -> Legacy112WorldMigrator.convertChunkForTest(chunk(200, 13), BLOCKS, List.of(air))); + assertThrows(IOException.class, () -> Legacy112WorldMigrator.convertChunkForTest(chunk(200, 13), BLOCKS, List.of(stone, stone))); + } + + @Test + void modernChunksNeverReachCompanionCallbacks() { + CompoundTag chunk = chunk(200, 13); + chunk.putInt("DataVersion", 4790); + CompoundTag before = chunk.copy(); + var extension = new Legacy112MigrationExtension() { + @Override public boolean prepareData(CompoundTag tag, int version, Payload payload) { + fail("Modern chunk reached legacy preparation"); + return false; + } + }; + assertThrows(IOException.class, () -> Legacy112WorldMigrator.convertChunkForTest(chunk, BLOCKS, List.of(extension))); + assertEquals(before, chunk); + } + + @Test + void detachedPlayersInBothLayoutsKeepTheirVersionAndModernFilesStayExact(@TempDir Path world) throws Exception { + LegacyNbt.writeCompressed(level(1343, null), world.resolve("level.dat").toFile()); + List versions = new ArrayList<>(); + var extension = new Legacy112MigrationExtension() { + @Override public boolean prepareData(CompoundTag tag, int version, Payload payload) { + assertEquals(Payload.PLAYER, payload); + versions.add(version); + prepareStack(stack(tag)); + return true; + } + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + fail("Detached player must be loaded through native PlayerDataStorage"); + } + }; + for (String layout : List.of("playerdata", "players/data")) { + Path directory = Files.createDirectories(world.resolve(layout)); + CompoundTag legacy = player(); + legacy.putInt("DataVersion", 1343); + LegacyNbt.writeCompressed(legacy, directory.resolve("legacy.dat").toFile()); + CompoundTag modern = player(); + modern.putInt("DataVersion", 4790); + LegacyNbt.writeCompressed(modern, directory.resolve("modern.dat").toFile()); + } + byte[] modern = Files.readAllBytes(world.resolve("players/data/modern.dat")); + byte[] old = Files.readAllBytes(world.resolve("playerdata/legacy.dat")); + Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension)); + assertEquals(List.of(1343, 1343), versions); + assertArrayEquals(modern, Files.readAllBytes(world.resolve("players/data/modern.dat"))); + assertArrayEquals(old, Files.readAllBytes(world.resolve("basemetals-1.12-region-backup/playerdata/legacy.dat"))); + assertEquals(1343, LegacyNbt.readCompressed(world.resolve("players/data/legacy.dat").toFile()).getIntOr("DataVersion", 0)); + Legacy112WorldMigrator.migrateIfNeeded(world, List.of(extension)); + assertEquals(2, versions.size()); + } + + @Test + void hostSeedSplitsThePlayerFixerWithoutComponentizingLegacyLevel(@TempDir Path world) throws Exception { + CompoundTag player = player(); + CompoundTag legacy = level(512, player); + Path levelDat = world.resolve("level.dat"); + LegacyNbt.writeCompressed(legacy, levelDat.toFile()); + byte[] original = Files.readAllBytes(levelDat); + List calls = new ArrayList<>(); + var extension = new Legacy112MigrationExtension() { + @Override public boolean prepareData(CompoundTag tag, int version, Payload payload) { + assertEquals(512, version); + assertEquals(Payload.PLAYER, payload); + prepareStack(stack(tag)); + calls.add("prepare"); + return true; + } + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + assertEquals("example:musket", stack(source).getStringOr("id", "")); + assertEquals(1, stack(result).getByteOr("Count", (byte) 0)); + assertFalse(stack(result).contains("components")); + result.putInt("companionEnergy", 125); + calls.add("after"); + } + }; + withExtension(extension, () -> LegacyWorldDataHook.prepareSelectedWorld(levelDat)); + assertEquals(List.of("prepare", "after"), calls); + CompoundTag seeded = LegacyNbt.readCompressed(world.resolve("playerdata/" + PLAYER + ".dat").toFile()); + assertEquals(4790, seeded.getIntOr("DataVersion", 0)); + assertStack(stack(seeded)); + assertEquals(125, seeded.getIntOr("companionEnergy", 0)); + CompoundTag disk = LegacyNbt.readCompressed(levelDat.toFile()).getCompoundOrEmpty("Data"); + assertEquals(512, disk.getIntOr("DataVersion", 0)); + assertFalse(stack(disk.getCompoundOrEmpty("Player")).contains("components")); + assertArrayEquals(original, Files.readAllBytes(world.resolve("basemetals-1.12-region-backup/level.dat"))); + } + + @Test + void currentLooseHostAuthorityWinsInEitherLayout(@TempDir Path root) throws Exception { + for (String layout : List.of("playerdata", "players/data")) { + Path world = Files.createDirectories(root.resolve(layout.replace('/', '-'))); + Path levelDat = world.resolve("level.dat"); + LegacyNbt.writeCompressed(level(512, player()), levelDat.toFile()); + Path existing = Files.createDirectories(world.resolve(layout)).resolve(PLAYER + ".dat"); + CompoundTag current = new CompoundTag(); + current.putInt("DataVersion", 4790); + current.putInt("currentAuthority", 91); + LegacyNbt.writeCompressed(current, existing.toFile()); + byte[] bytes = Files.readAllBytes(existing); + withExtension(new Legacy112MigrationExtension() { + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + fail("Existing authoritative host file must not be replaced"); + } + }, () -> LegacyWorldDataHook.prepareSelectedWorld(levelDat)); + assertArrayEquals(bytes, Files.readAllBytes(existing)); + if (layout.equals("players/data")) assertFalse(Files.exists(world.resolve("playerdata/" + PLAYER + ".dat"))); + } + } + + @Test + void postFlattenCallbacksCooperateInBothOrdersAndPreserveTheirSource() { + for (boolean reverse : new boolean[] {false, true}) { + CompoundTag player = player(); + prepareStack(stack(player)); + CompoundTag before = player.copy(); + var first = new Legacy112MigrationExtension() { + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + assertEquals(before, source); + result.putInt("firstCompanion", 11); + } + }; + var second = new Legacy112MigrationExtension() { + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + assertEquals(before, source); + result.putInt("secondCompanion", 22); + } + }; + CompoundTag fixed = Legacy112WorldMigrator.flattenEmbeddedPlayer(player.copy(), 512, + reverse ? List.of(second, first) : List.of(first, second)); + assertEquals(before, player); + assertEquals(11, fixed.getIntOr("firstCompanion", 0)); + assertEquals(22, fixed.getIntOr("secondCompanion", 0)); + assertStack(stack(fixed)); + } + } + + @Test + void duplicateAndNullRegistrationsFailClosed() throws Exception { + withExtension(new Legacy112MigrationExtension() {}, () -> { + assertThrows(IllegalArgumentException.class, () -> Legacy112MigrationExtension.register(EXTENSION, new Legacy112MigrationExtension() {})); + assertThrows(NullPointerException.class, () -> Legacy112MigrationExtension.register(null, new Legacy112MigrationExtension() {})); + assertThrows(NullPointerException.class, () -> Legacy112MigrationExtension.register(Identifier.parse("example:null"), null)); + }); + } + + @Test + void namedBookEnchantmentsAreRestoredBeforeComponentsWithoutChangingSlotsOrCounts() { + for (int version : new int[] {512, 1343}) { + CompoundTag player = player(); + ListTag enderItems = new ListTag(); + CompoundTag book = new CompoundTag(); + book.putString("id", "minecraft:enchanted_book"); + book.putByte("Count", (byte) 1); + book.putByte("Slot", (byte) 9); + CompoundTag enchantment = new CompoundTag(); + enchantment.putString("id", "example:precision"); + enchantment.putShort("lvl", (short) 3); + ListTag enchantments = new ListTag(); + enchantments.add(enchantment); + CompoundTag tag = new CompoundTag(); + tag.put("StoredEnchantments", enchantments); + book.put("tag", tag); + enderItems.add(book); + player.put("EnderItems", enderItems); + CompoundTag original = player.copy(); + var extension = new Legacy112MigrationExtension() { + @Override public void afterPlayerFlattening(CompoundTag source, CompoundTag result) { + assertEquals(original, source); + CompoundTag fixedBook = result.getListOrEmpty("EnderItems").getCompoundOrEmpty(0); + assertEquals(book.get("Count"), fixedBook.get("Count")); + assertEquals(book.get("Slot"), fixedBook.get("Slot")); + assertFalse(fixedBook.contains("components")); + fixedBook.getCompoundOrEmpty("tag").getListOrEmpty("StoredEnchantments") + .getCompoundOrEmpty(0).putString("id", "example:precision"); + } + }; + CompoundTag fixed = Legacy112WorldMigrator.flattenEmbeddedPlayer(player, version, List.of(extension)); + CompoundTag fixedBook = fixed.getListOrEmpty("EnderItems").getCompoundOrEmpty(0); + assertEquals(1, fixedBook.getIntOr("count", 0)); + assertEquals(9, fixedBook.getByteOr("Slot", (byte) 0)); + assertEquals(3, fixedBook.getCompoundOrEmpty("components") + .getCompoundOrEmpty("minecraft:stored_enchantments").getIntOr("example:precision", 0)); + } + } + + private static void withExtension(Legacy112MigrationExtension extension, Runnable action) throws Exception { + Legacy112MigrationExtension.register(EXTENSION, extension); + try { + action.run(); + } finally { + var field = Legacy112WorldMigrator.class.getDeclaredField("EXTENSIONS"); + field.setAccessible(true); + ((Map) field.get(null)).remove(EXTENSION); + Legacy112WorldMigrator.beginWorldLoad(); + } + } + + private static Legacy112MigrationExtension decoder(BlockState state) { + return new Legacy112MigrationExtension() { + @Override public BlockState decodeBlockState(Identifier id, int metadata) { return state; } + }; + } + + private static CompoundTag level(int version, CompoundTag player) { + CompoundTag root = new CompoundTag(); + CompoundTag data = new CompoundTag(); + data.putInt("DataVersion", version); + if (player != null) data.put("Player", player); + root.put("Data", data); + return root; + } + + private static CompoundTag player() { + CompoundTag player = new CompoundTag(); + player.putLong("UUIDMost", PLAYER.getMostSignificantBits()); + player.putLong("UUIDLeast", PLAYER.getLeastSignificantBits()); + ListTag inventory = new ListTag(); + inventory.add(legacyStack()); + player.put("Inventory", inventory); + return player; + } + + private static CompoundTag stack(CompoundTag player) { + return player.getListOrEmpty("Inventory").getCompoundOrEmpty(0); + } + + private static CompoundTag legacyStack() { + CompoundTag stack = new CompoundTag(); + stack.putString("id", "example:musket"); + stack.putByte("Count", (byte) 1); + stack.putByte("Slot", (byte) 4); + stack.putShort("Damage", (short) 3); + return stack; + } + + private static void prepareStack(CompoundTag stack) { + CompoundTag tag = new CompoundTag(); + tag.putInt("Damage", stack.getShortOr("Damage", (short) 0)); + tag.putInt("companionCharge", 42); + stack.put("tag", tag); + } + + private static void assertStack(CompoundTag stack) { + assertEquals("example:musket", stack.getStringOr("id", "")); + assertEquals(1, stack.getIntOr("count", 0)); + assertEquals(4, stack.getByteOr("Slot", (byte) 0)); + assertFalse(stack.contains("Count")); + assertFalse(stack.contains("tag")); + assertEquals(3, stack.getCompoundOrEmpty("components").getIntOr("minecraft:damage", 0)); + assertEquals(42, stack.getCompoundOrEmpty("components").getCompoundOrEmpty("minecraft:custom_data").getIntOr("companionCharge", 0)); + } + + private static CompoundTag chunk(int block, int metadata) { + CompoundTag root = new CompoundTag(); + root.putInt("DataVersion", 1343); + CompoundTag level = new CompoundTag(); + level.putBoolean("TerrainPopulated", true); + level.putBoolean("LightPopulated", true); + level.putByteArray("Biomes", new byte[256]); + level.putIntArray("HeightMap", new int[256]); + CompoundTag section = new CompoundTag(); + byte[] blocks = new byte[4096]; + blocks[0] = (byte) block; + section.putByteArray("Blocks", blocks); + byte[] data = new byte[2048]; + data[0] = (byte) metadata; + section.putByteArray("Data", data); + section.putByte("Y", (byte) 0); + ListTag sections = new ListTag(); + sections.add(section); + level.put("Sections", sections); + ListTag tiles = new ListTag(); + if (block == 54) { + CompoundTag chest = new CompoundTag(); + chest.putString("id", "minecraft:chest"); + ListTag items = new ListTag(); + items.add(legacyStack()); + chest.put("Items", items); + tiles.add(chest); + } + level.put("TileEntities", tiles); + level.put("Entities", new ListTag()); + root.put("Level", level); + return root; + } +} diff --git a/src/test/java/zone/moddev/mc/basemetals/migration/NativeMigrationHooksTest.java b/src/test/java/zone/moddev/mc/basemetals/migration/NativeMigrationHooksTest.java index bcfd8bfb7..1ffffdc7f 100644 --- a/src/test/java/zone/moddev/mc/basemetals/migration/NativeMigrationHooksTest.java +++ b/src/test/java/zone/moddev/mc/basemetals/migration/NativeMigrationHooksTest.java @@ -1,6 +1,8 @@ package zone.moddev.mc.basemetals.migration; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Modifier; @@ -23,6 +25,43 @@ /** Catches renamed injection targets before a packaged world is opened. */ class NativeMigrationHooksTest { + @Test + void chunkFinalizationCannotCancelCompanionReturnHooks() throws Exception { + var node = new org.objectweb.asm.tree.ClassNode(); + try (var input = getClass().getResourceAsStream( + "/zone/moddev/mc/basemetals/mixin/SimpleRegionStorageMixin.class")) { + new org.objectweb.asm.ClassReader(input).accept(node, 0); + } + var finish = node.methods.stream().filter(method -> method.name.equals("basemetals$finishChunk")) + .findFirst().orElseThrow(); + var annotations = new java.util.ArrayList(); + if (finish.visibleAnnotations != null) annotations.addAll(finish.visibleAnnotations); + if (finish.invisibleAnnotations != null) annotations.addAll(finish.invisibleAnnotations); + var inject = annotations.stream().filter(annotation -> annotation.desc.endsWith("/Inject;")) + .findFirst().orElseThrow(); + int cancellation = inject.values.indexOf("cancellable"); + assertTrue(cancellation < 0 || !Boolean.TRUE.equals(inject.values.get(cancellation + 1))); + for (var instruction : finish.instructions.toArray()) { + if (instruction instanceof org.objectweb.asm.tree.MethodInsnNode call) { + assertFalse(call.name.equals("setReturnValue") || call.name.equals("cancel")); + } + } + } + + @Test + void finalizationPreservesCompanionDataInEitherReturnOrder() { + for (boolean companionFirst : new boolean[] {true, false}) { + CompoundTag root = new CompoundTag(); + root.putBoolean("BaseMetalsLegacyPreserveChunk", true); + if (companionFirst) root.putInt("companionEnergy", 125); + assertSame(root, LegacyWorldDataHook.finalizeLegacyChunk(root)); + if (!companionFirst) root.putInt("companionEnergy", 125); + assertFalse(root.contains("BaseMetalsLegacyPreserveChunk")); + assertEquals("full", root.getStringOr("Status", "")); + assertEquals(125, root.getIntOr("companionEnergy", 0)); + } + } + @Test void legacyDurabilityDoesNotRequireAnEarlyItemStack() throws Exception { for (var form : zone.moddev.mc.basemetals.config.MaterialForm.values()) { From 331fa46914f854db71a8c33500cc559a8878b7d3 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 12 Nov 2026 04:49:23 +0000 Subject: [PATCH 2/3] Share saved world registries before companion conversion --- CHANGELOG.txt | 1 + README.md | 5 ++ .../Legacy112MigrationExtension.java | 7 ++ .../migration/Legacy112WorldMigrator.java | 4 +- .../LegacyWorldContextExtensionTest.java | 65 +++++++++++++++++++ 5 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/test/java/zone/moddev/mc/basemetals/migration/LegacyWorldContextExtensionTest.java diff --git a/CHANGELOG.txt b/CHANGELOG.txt index c669fa506..9a91ddce5 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,4 +1,5 @@ Base Metals 3.1.2.2601021 +- Supply companion mods with the saved world registry before conversion, so old item enchantments use the right IDs in either hook order. - Let companion mods preserve legacy machine directions, inventories and item wear before conversion. - Keep companion chunk return callbacks running in either transformer order. - Preserve host player data before native 26.1 world-file relocation without replacing existing player files. diff --git a/README.md b/README.md index fa2044d9f..85337b239 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ Additional Loot Tables are no longer needed. The build uses Forge `64.0.9` and is also tested on `64.1.5`. +The local companion-migration patch gives basePower the saved world's registry +before conversion begins. This keeps old machine items and enchantments intact +without depending on which mod's world hook runs first. The patch is being +qualified locally and has not been published. + This version uses OreSpawn `4.1.0.2601021`. Put `BaseMetals-3.1.2.2601021.jar` and a compatible OreSpawn JAR in the `mods` directory. MMDLib and Additional Loot Tables must not be carried forward from 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 index 1aec911ec..12c852a07 100644 --- a/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java +++ b/src/main/java/zone/moddev/mc/basemetals/api/migration/Legacy112MigrationExtension.java @@ -9,6 +9,13 @@ public interface Legacy112MigrationExtension { enum Payload { CHUNK, PLAYER } + /** + * Supplies this selected world's saved registry context before any conversion. + * Each extension receives its own copy. Modern worlds are included so a + * companion can clear registry IDs retained from a previous world. + */ + default void prepareWorld(CompoundTag levelSnapshot) { } + /** Mutates owned legacy data before vanilla fixes and the block-entity snapshot. */ default boolean prepareData(CompoundTag tag, int dataVersion, Payload payload) { return false; } 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 13ce60822..ae917a859 100644 --- a/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java +++ b/src/main/java/zone/moddev/mc/basemetals/migration/Legacy112WorldMigrator.java @@ -97,9 +97,11 @@ static void beginWorldLoad() { /** Keep this load's provenance before Minecraft rewrites level.dat. */ static void prepareWorld(Path levelDat, CompoundTag root) { + for (Legacy112MigrationExtension extension : EXTENSIONS.values()) { + extension.prepareWorld(root.copy()); + } if (!isLegacyLevel(root)) return; - CompoundTag data = root.getCompoundOrEmpty("Data"); preparedWorld = levelDat.toAbsolutePath().normalize().getParent(); } diff --git a/src/test/java/zone/moddev/mc/basemetals/migration/LegacyWorldContextExtensionTest.java b/src/test/java/zone/moddev/mc/basemetals/migration/LegacyWorldContextExtensionTest.java new file mode 100644 index 000000000..730c2b1b3 --- /dev/null +++ b/src/test/java/zone/moddev/mc/basemetals/migration/LegacyWorldContextExtensionTest.java @@ -0,0 +1,65 @@ +package zone.moddev.mc.basemetals.migration; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import zone.moddev.mc.basemetals.api.migration.Legacy112MigrationExtension; + +import static org.junit.jupiter.api.Assertions.*; + +class LegacyWorldContextExtensionTest { + private static final List VERSIONS = new ArrayList<>(); + private static final List SECOND = new ArrayList<>(); + + @BeforeAll static void register() { + Legacy112MigrationExtension.register(Identifier.parse("contexttest:a_mutator"), + new Legacy112MigrationExtension() { + @Override public void prepareWorld(CompoundTag levelSnapshot) { + VERSIONS.add(levelSnapshot.getCompoundOrEmpty("Data").getIntOr("DataVersion", 0)); + levelSnapshot.remove("Protected"); + levelSnapshot.putBoolean("Injected", true); + } + }); + Legacy112MigrationExtension.register(Identifier.parse("contexttest:b_observer"), + new Legacy112MigrationExtension() { + @Override public void prepareWorld(CompoundTag levelSnapshot) { + SECOND.add(levelSnapshot.copy()); + } + }); + } + + @BeforeEach void clearObservations() { + VERSIONS.clear(); + SECOND.clear(); + } + + @Test void companionsReceiveIndependentSnapshotsBeforeLegacyConversion() { + CompoundTag root = snapshot(512), before = root.copy(); + Legacy112WorldMigrator.prepareWorld(Path.of("build", "context-test", "level.dat"), root); + assertEquals(List.of(512), VERSIONS); + assertEquals(List.of(before), SECOND, "One extension must not change another extension's snapshot"); + assertEquals(before, root, "Companions must not mutate the authoritative world root"); + } + + @Test void modernWorldSelectionAlsoNotifiesCompanionsToResetLegacyContext() { + CompoundTag legacy = snapshot(1343), modern = snapshot(4790), before = modern.copy(); + Legacy112WorldMigrator.prepareWorld(Path.of("build", "context-old", "level.dat"), legacy); + Legacy112WorldMigrator.prepareWorld(Path.of("build", "context-new", "level.dat"), modern); + assertEquals(List.of(1343, 4790), VERSIONS); + assertEquals(before, SECOND.get(1)); + assertEquals(before, modern); + } + + private static CompoundTag snapshot(int version) { + CompoundTag root = new CompoundTag(), data = new CompoundTag(); + data.putInt("DataVersion", version); + root.put("Data", data); + root.putString("Protected", "saved-registry-snapshot"); + return root; + } +} From 3070104b3aa0d9cdf68aa5f415fbb093ab6866d2 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 12 Nov 2026 15:16:43 +0000 Subject: [PATCH 3/3] Document the qualified companion migration patch --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 85337b239..94c925e2e 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ Additional Loot Tables are no longer needed. The build uses Forge `64.0.9` and is also tested on `64.1.5`. -The local companion-migration patch gives basePower the saved world's registry +The companion-migration patch gives basePower the saved world's registry before conversion begins. This keeps old machine items and enchantments intact -without depending on which mod's world hook runs first. The patch is being -qualified locally and has not been published. +without depending on which mod's world hook runs first. It has passed the +companion upgrade, restart and hook-order checks, including the accepted +basePower, baseSteam and baseElectric 26.1.2 test worlds. This version uses OreSpawn `4.1.0.2601021`. Put `BaseMetals-3.1.2.2601021.jar` and a compatible OreSpawn JAR in the `mods`