diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9658c4009..5a193371e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,7 @@ jobs: gradle_args=( clean check build javadoc verifyReleaseDependencies verifyReleaseArtifacts writeReleaseChecksums prepareEclipse packagedForgeRuntimeTest + packagedForgeLegacyMigrationTest -PlegacyDependencyVerificationRepository="${{ steps.dependencies.outputs.repository }}" -PpackagedForgeRuntimeRoot="${{ steps.forge.outputs.runtime }}" --no-daemon --stacktrace --max-workers=2 @@ -177,9 +178,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/BaseMetals-2.6.0.112021.jar - build/libs/BaseMetals-2.6.0.112021-sources.jar - build/libs/BaseMetals-2.6.0.112021-javadoc.jar + build/libs/BaseMetals-2.6.1.112021.jar + build/libs/BaseMetals-2.6.1.112021-sources.jar + build/libs/BaseMetals-2.6.1.112021-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt @@ -196,4 +197,6 @@ jobs: build/packaged-forge-*-run/logs/** build/packaged-forge-*-run/*-console.txt build/packaged-forge-*-run/crash-reports/** + build/packaged-forge-legacy-run/*-save.properties + build/packaged-forge-legacy-run/legacy-*.properties build/problems/** diff --git a/CHANGELOG.txt b/CHANGELOG.txt index be5094717..8d3b23bbc 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,15 @@ +Base Metals 2.6.1.112021 — Minecraft 1.12.2 + +- Fixed recovery of mercury blocks, carbon powder, iron nuggets and metal door + items when loading older Base Metals worlds. Counts, damage, enchantments, + names and other item data are preserved. +- Fixed old items disappearing from chunks first visited after saving and + restarting an upgraded world. Automated checks now cover first load, + reload and these later visits. +- Keep the matching materials enabled and back up the complete world before + upgrading. Items already lost in an earlier save need a pre-loss backup; + containers and entities from other mods still need compatible versions. + Base Metals 2.6.0.112021 — Minecraft 1.12.2 Before upgrading diff --git a/README.md b/README.md index 76fe9662c..968a8ddfc 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ Base Metals adds metals, alloys, equipment, building blocks, and the crackhammer Crush ores into powder, smelt the powder into ingots, or combine it into alloy blends. The metals also work with supported processing and tool-building mods. -This branch builds **2.6.0.112021** for Minecraft 1.12.2 and Forge. It keeps the -existing block and item names for saved worlds, fixes long-standing gameplay -issues, and adds an optional Low Fantasy content mode. See the +This branch builds **2.6.1.112021** for Minecraft 1.12.2 and Forge. This update +fixes missing blocks and items when upgrading older Base Metals worlds. It +also keeps the gameplay fixes and optional Low Fantasy mode from 2.6.0. See the [changelog](CHANGELOG.txt) for the release changes. ## Installation @@ -31,6 +31,20 @@ Put the jars in the instance's `mods` folder. Minecraft 1.12.2 runs on Java 8. Back up your worlds and configuration before upgrading. MMD OreSpawn is the world-generation library, not the unrelated creature mod with a similar name. +## Older worlds + +Older Base Metals mercury blocks, carbon powder, iron nuggets and metal door +items load under their current names. This also covers chunks first visited +after the upgraded world has already been saved and restarted. Stack counts, +damage, enchantments, names and other item data are retained. + +Keep the corresponding materials enabled during the upgrade. The fix cannot +restore items already lost in an earlier save; use a backup from before the +loss instead. + +Test a complete copy of the world first. Containers and entities supplied by +other mods still need their own compatible versions and upgrade support. + ## Metals and alloys The natural materials are Antimony, Bismuth, Copper, Lead, Mercury, Nickel, @@ -129,7 +143,7 @@ The [build-script guide](gradle/README.md) explains the supporting scripts. The [release guide](docs/releasing.md) covers publication from the MMD Actions tab. Release artifacts use the Maven coordinates -`zone.moddev.mc.basemetals:BaseMetals:2.6.0.112021`. Java packages remain under +`zone.moddev.mc.basemetals:BaseMetals:2.6.1.112021`. Java packages remain under `com.mcmoddev.basemetals`; this release does not move saved registry names. Report bugs through the [issue tracker](https://github.com/MinecraftModDevelopmentMods/BaseMetals/issues). diff --git a/docs/world-generation.md b/docs/world-generation.md index e9a56e342..24d59154a 100644 --- a/docs/world-generation.md +++ b/docs/world-generation.md @@ -1,6 +1,6 @@ # Base Metals world generation -Base Metals 2.6.0 requires OreSpawn 4.1.0.112021 or newer in the 4.x line. +Base Metals 2.6 requires OreSpawn 4.1.0.112021 or newer in the 4.x line. OreSpawn is the only Base Metals ore generator. Base Metals does not register a fallback generator. diff --git a/gradle.properties b/gradle.properties index 619cba34b..1a8a97dce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ org.gradle.parallel=false net.minecraftforge.gradle.merge-source-sets=false mod_group=zone.moddev.mc.basemetals -mod_version=2.6.0.112021 +mod_version=2.6.1.112021 # Generic release-dispatcher metadata. minecraft_version=1.12.2 diff --git a/gradle/README.md b/gradle/README.md index 891d04f9f..8b40d6526 100644 --- a/gradle/README.md +++ b/gradle/README.md @@ -19,6 +19,8 @@ of that main path: - `verification/packaged-forge-dependencies.gradle` checks required OreSpawn behavior and preservation of an existing override. - `verification/packaged-forge-migration.gradle` covers OreSpawn 3 migration. +- `verification/packaged-forge-legacy-saves.gradle` checks old Base Metals IDs + on first load, reload, and a later visit to a previously unopened chunk. - `verification/packaged-forge-cofh.gradle` covers the optional CoFH World coexistence profile. - `verification/packaged-forge-tinkers.gradle` covers Tinkers, TAIGA, @@ -38,3 +40,14 @@ variables from another script's scope. When adding a task, put it beside the workflow it supports. Keep task names stable because CI and the release dispatcher call them directly. + +CI runs `packagedForgeLegacyMigrationTest` against the packaged release jar. +It creates a small disposable Anvil save with the twenty historical item +names and the old Mercury block name. The test checks item counts, damage, +names, enchantments and custom data after each restart. A distant region must +remain byte-for-byte unchanged until the test visits it; its items must then +convert and survive another restart. No private world backup is needed. + +For a local run, supply `-PpackagedForgeRuntimeRoot=` as +with the other packaged-server tests. The fixture, logs and results stay in +`build/packaged-forge-legacy-run`; they are not release artifacts. diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle index f77a05fb1..e74e89fc7 100644 --- a/gradle/release/artifacts.gradle +++ b/gradle/release/artifacts.gradle @@ -165,6 +165,8 @@ tasks.register('verifyReleaseArtifacts') { 'com/mcmoddev/basemetals/recipe/PolicyCrusherRecipe.class', 'com/mcmoddev/basemetals/recipe/PolicyRecipe.class', 'com/mcmoddev/basemetals/worldgen/BaseMetalsWorldgenProvider.class', + 'com/mcmoddev/basemetals/LegacyMappings.class', + 'com/mcmoddev/basemetals/LegacyStackFix.class', 'assets/mmdlib/lang/en_us.lang', 'assets/mmdlib/lang/es_es.lang' ] diff --git a/gradle/verification/packaged-forge-legacy-saves.gradle b/gradle/verification/packaged-forge-legacy-saves.gradle new file mode 100644 index 000000000..5326bf865 --- /dev/null +++ b/gradle/verification/packaged-forge-legacy-saves.gradle @@ -0,0 +1,80 @@ +def runtime = project.ext.baseMetalsPackagedForge +File runDirectory = layout.buildDirectory.dir('packaged-forge-legacy-run').get().asFile +File marker = new File(runDirectory, 'legacy-probe.properties') + +def prepare = tasks.register('preparePackagedLegacySave') { + dependsOn runtime.releaseJar, runtime.packagedLegacyRuntimeProbeJar, + tasks.named('verifyReleaseDependencies') + doLast { + // Never point the cleanup or fixture writer at a player's save. + if (!runDirectory.canonicalFile.toPath().startsWith(layout.buildDirectory.get().asFile.canonicalFile.toPath())) { + throw new GradleException('The legacy fixture must stay inside build/') + } + runtime.stagePackagedForgeBase(runDirectory) + copy { + from layout.buildDirectory.file("libs/BaseMetals-${project.version}.jar") + from layout.buildDirectory.file('runtime-probes/basemetals-legacy-runtime-probe.jar') + from runtime.originalArtifact(configurations.legacyDependencyArtifacts, + runtime.dependencySpecs.mmdlib.coordinate) + from runtime.originalArtifact(configurations.legacyDependencyArtifacts, + runtime.dependencySpecs.orespawn4.coordinate) + into new File(runDirectory, 'mods') + } + File properties = new File(runDirectory, 'server.properties') + properties.setText(properties.getText('UTF-8').replace('level-type=default', 'level-type=flat'), 'UTF-8') + } +} + +def previous = prepare +['Seed', 'First', 'Reload', 'Delayed', 'Stable'].each { phase -> + def dependency = previous + def boot = tasks.register("packagedLegacySave${phase}", Exec) { + dependsOn dependency + workingDir runDirectory + timeout = java.time.Duration.ofMinutes(3) + ignoreExitValue = true + doFirst { + marker.delete() + // File-backed output survives assertion failures and process timeouts. + File console = new File(runDirectory, "${phase.toLowerCase()}-console.txt") + standardOutput = new FileOutputStream(console) + errorOutput = standardOutput + commandLine runtime.java8Launcher.get().executablePath.asFile, + '-Xmx2G', '-Dforge.logging.console.level=info', + '-Dbasemetals.legacyProbe.ciFixture=true', + "-Dbasemetals.legacyProbe.phase=${phase.toLowerCase()}", + "-Dbasemetals.legacyProbe.marker=${marker.absolutePath}", + '-jar', new File(runDirectory, + "forge-${project.minecraft_version}-${project.forge_version}-universal.jar"), 'nogui' + } + doLast { + standardOutput.close() + if (executionResult.get().exitValue != 0 || !marker.isFile()) { + throw new GradleException("Legacy save ${phase} failed; see ${runDirectory}") + } + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('phase') != phase.toLowerCase() || result.getProperty('passed') != 'true') { + throw new GradleException("Invalid legacy save result: ${result}") + } + def failures = runtime.runtimeLogFailures(runDirectory) + if (!failures.isEmpty() || !fileTree(runDirectory) { include 'crash-reports/*.txt' }.isEmpty()) { + throw new GradleException("Legacy save ${phase} logged runtime failures: ${failures.take(10)}") + } + } + } + + previous = tasks.register("verifyLegacySave${phase}", JavaExec) { + dependsOn boot + javaLauncher = runtime.java8Launcher + classpath = sourceSets.test.runtimeClasspath + mainClass = 'com.mcmoddev.basemetals.test.LegacySaveFixture' + args phase == 'Seed' ? 'prepare' : phase.toLowerCase(), runDirectory.absolutePath + } +} + +tasks.register('packagedForgeLegacyMigrationTest') { + group = 'verification' + description = 'Checks old registry names on first load, reload, and a later visit to an unopened chunk.' + dependsOn previous +} diff --git a/gradle/verification/packaged-forge.gradle b/gradle/verification/packaged-forge.gradle index 21d4d056a..ade4b599b 100644 --- a/gradle/verification/packaged-forge.gradle +++ b/gradle/verification/packaged-forge.gradle @@ -29,6 +29,31 @@ tasks.register('packagedRuntimeProbes') { dependsOn packagedRuntimeProbeJar } +tasks.register('legacyRuntimeProbeJar', Jar) { + group = 'verification' + description = 'Builds the test-only legacy-save registry and inventory probe.' + dependsOn tasks.named('testClasses') + archiveBaseName = 'basemetals-legacy-runtime-probe' + archiveVersion = '' + archiveClassifier = 'deobf' + destinationDirectory = layout.buildDirectory.dir('runtime-probes-dev') + from(sourceSets.test.output) { + include 'com/mcmoddev/basemetals/test/BaseMetalsLegacyRuntimeProbe*.class' + include 'com/mcmoddev/basemetals/test/LegacySaveFixture.class' + } +} + +def packagedLegacyRuntimeProbeJar = renamer.classes(tasks.named('legacyRuntimeProbeJar', Jar)) { + map.from minecraft.dependency.toSrgFile + archiveClassifier = null + output = layout.buildDirectory.file('runtime-probes/basemetals-legacy-runtime-probe.jar') +} + +tasks.register('packagedLegacyRuntimeProbe') { + group = 'verification' + dependsOn packagedLegacyRuntimeProbeJar +} + tasks.register('tinkersRuntimeProbeJar', Jar) { group = 'verification' description = 'Builds the mapped test-only Tinkers/TAIGA compatibility probe.' @@ -372,6 +397,7 @@ ext.baseMetalsPackagedForge = [ releaseJar : releaseJar, java8Launcher : java8Launcher, packagedRuntimeProbeJar : packagedRuntimeProbeJar, + packagedLegacyRuntimeProbeJar : packagedLegacyRuntimeProbeJar, packagedTinkersRuntimeProbeJar : packagedTinkersRuntimeProbeJar, packagedClientConfigRuntimeProbeJar : packagedClientConfigRuntimeProbeJar, packagedForgeRuntimeRoot : packagedForgeRuntimeRoot, @@ -384,6 +410,7 @@ ext.baseMetalsPackagedForge = [ apply from: 'gradle/verification/packaged-forge-dependencies.gradle' apply from: 'gradle/verification/packaged-forge-migration.gradle' +apply from: 'gradle/verification/packaged-forge-legacy-saves.gradle' apply from: 'gradle/verification/packaged-forge-cofh.gradle' apply from: 'gradle/verification/packaged-forge-tinkers.gradle' apply from: 'gradle/verification/packaged-advancements.gradle' diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle index 24b75d5af..48caef782 100644 --- a/gradle/verification/support.gradle +++ b/gradle/verification/support.gradle @@ -82,7 +82,7 @@ tasks.register('verifyReleaseConfiguration') { description = 'Validates the complete Base Metals 1.12 release identity and dependency contract.' dependsOn tasks.named('verifyReleaseToolchains') doLast { - if (project.mod_version != '2.6.0.112021' + if (project.mod_version != '2.6.1.112021' || project.minecraft_version != '1.12.2' || project.forge_version != '14.23.5.2859' || project.mappings_channel != 'stable' @@ -130,7 +130,7 @@ tasks.register('verifyReleaseConfiguration') { throw new GradleException('Unexpected published OreSpawn 4.1 CurseForge artifact identity') } String source = file('src/main/java/com/mcmoddev/basemetals/BaseMetals.java').getText('UTF-8') - if (!source.contains('VERSION = "2.6.0.112021"') + if (!source.contains('VERSION = "2.6.1.112021"') || !source.contains('certificateFingerprint = ""') || !source.contains('required-after:orespawn@[4.1.0.112021,5.0.0)')) { throw new GradleException('Base Metals source version or unsigned fingerprint contract drifted') diff --git a/gradle/verification/workflows.gradle b/gradle/verification/workflows.gradle index ed5f9f10b..de4447aab 100644 --- a/gradle/verification/workflows.gradle +++ b/gradle/verification/workflows.gradle @@ -9,6 +9,7 @@ tasks.register('verifyWorkflowContracts') { 'master-1.12', 'feature/**', 'Cold Forge bootstrap', 'Build, test, and audit', 'stage-legacy-dependencies.sh', 'packagedForgeRuntimeTest', 'prepareEclipse', + 'packagedForgeLegacyMigrationTest', 'Verify reproducible release archives' ], 'codeql-analysis.yml': [ diff --git a/src/main/java/com/mcmoddev/basemetals/BaseMetals.java b/src/main/java/com/mcmoddev/basemetals/BaseMetals.java index 87dfcc7ef..847fc96aa 100644 --- a/src/main/java/com/mcmoddev/basemetals/BaseMetals.java +++ b/src/main/java/com/mcmoddev/basemetals/BaseMetals.java @@ -45,7 +45,7 @@ public final class BaseMetals { protected static final String NAME = "Base Metals"; /** Release version in the shared MMD major.minor.patch.target format. */ - protected static final String VERSION = "2.6.0.112021"; + protected static final String VERSION = "2.6.1.112021"; protected static final String UPDATEJSON = SharedStrings.UPDATE_JSON_URL + "BaseMetals/master/update.json"; @@ -104,13 +104,13 @@ public static void postInit(final FMLPostInitializationEvent event) { /** Restores historical block IDs when Forge loads an older save. */ @SubscribeEvent - public void onRemapBlock(final RegistryEvent.MissingMappings event) { + public static void onRemapBlock(final RegistryEvent.MissingMappings event) { proxy.onRemapBlock(event); } /** Restores historical item IDs when Forge loads an older save. */ @SubscribeEvent - public void onRemapItem(final RegistryEvent.MissingMappings event) { + public static void onRemapItem(final RegistryEvent.MissingMappings event) { proxy.onRemapItem(event); } } diff --git a/src/main/java/com/mcmoddev/basemetals/LegacyMappings.java b/src/main/java/com/mcmoddev/basemetals/LegacyMappings.java new file mode 100644 index 000000000..6fca88196 --- /dev/null +++ b/src/main/java/com/mcmoddev/basemetals/LegacyMappings.java @@ -0,0 +1,49 @@ +package com.mcmoddev.basemetals; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import net.minecraft.util.datafix.FixTypes; +import net.minecraftforge.common.util.ModFixs; +import net.minecraftforge.fml.common.FMLCommonHandler; + +/** Historical registry names whose equivalent content is still available. */ +public final class LegacyMappings { + + private static final Map ITEMS = itemAliases(); + + private LegacyMappings() { + } + + /** Registers raw-stack recovery before chunks, players and entities are deserialized. */ + public static void registerDataFixer() { + ModFixs fixes = FMLCommonHandler.instance().getDataFixer().init(BaseMetals.MODID, 1); + LegacyStackFix fix = new LegacyStackFix(); + for (FixTypes type : new FixTypes[] { FixTypes.CHUNK, FixTypes.PLAYER, FixTypes.ENTITY, + FixTypes.BLOCK_ENTITY, FixTypes.ITEM_INSTANCE }) fixes.registerFix(type, fix); + } + + /** Returns the current item name, or null for an unrelated registry entry. */ + public static String itemTarget(final String name) { + return ITEMS.get(name); + } + + /** Returns the current fluid-block name, or null for an unrelated entry. */ + public static String blockTarget(final String name) { + return "basemetals:liquid_mercury".equals(name) ? "basemetals:mercury" : null; + } + + private static Map itemAliases() { + final Map aliases = new LinkedHashMap<>(); + aliases.put("basemetals:liquid_mercury", "basemetals:mercury"); + aliases.put("basemetals:carbon_powder", "basemetals:coal_powder"); + aliases.put("basemetals:iron_nugget", "minecraft:iron_nugget"); + for (String material : new String[] { "adamantine", "aquarium", "brass", "bronze", + "coldiron", "copper", "cupronickel", "electrum", "invar", "lead", "mithril", + "nickel", "platinum", "silver", "starsteel", "steel", "tin" }) { + aliases.put("basemetals:" + material + "_door_item", "basemetals:" + material + "_door"); + } + return Collections.unmodifiableMap(aliases); + } +} diff --git a/src/main/java/com/mcmoddev/basemetals/LegacyStackFix.java b/src/main/java/com/mcmoddev/basemetals/LegacyStackFix.java new file mode 100644 index 000000000..f32839efa --- /dev/null +++ b/src/main/java/com/mcmoddev/basemetals/LegacyStackFix.java @@ -0,0 +1,48 @@ +package com.mcmoddev.basemetals; + +import java.util.function.Predicate; + +import net.minecraft.nbt.NBTBase; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.datafix.IFixableData; +import net.minecraftforge.fml.common.registry.ForgeRegistries; + +/** Recovers named item stacks even when their chunk was not opened on the first upgrade. */ +final class LegacyStackFix implements IFixableData { + + private final Predicate available; + + LegacyStackFix() { + this(name -> ForgeRegistries.ITEMS.containsKey(new ResourceLocation(name))); + } + + LegacyStackFix(final Predicate available) { + this.available = available; + } + + @Override + public int getFixVersion() { + return 1; + } + + @Override + public NBTTagCompound fixTagCompound(final NBTTagCompound compound) { + rewrite(compound); + return compound; + } + + private void rewrite(final NBTBase value) { + if (value instanceof NBTTagCompound) { + NBTTagCompound compound = (NBTTagCompound) value; + if (compound.hasKey("id", 8) && compound.hasKey("Count", 99)) { + String target = LegacyMappings.itemTarget(compound.getString("id")); + if (target != null && available.test(target)) compound.setString("id", target); + } + for (String key : compound.getKeySet()) rewrite(compound.getTag(key)); + } else if (value instanceof NBTTagList) { + for (NBTBase child : (NBTTagList) value) rewrite(child); + } + } +} diff --git a/src/main/java/com/mcmoddev/basemetals/proxy/CommonProxy.java b/src/main/java/com/mcmoddev/basemetals/proxy/CommonProxy.java index 5141d6096..278e56882 100644 --- a/src/main/java/com/mcmoddev/basemetals/proxy/CommonProxy.java +++ b/src/main/java/com/mcmoddev/basemetals/proxy/CommonProxy.java @@ -3,22 +3,23 @@ import javax.annotation.Nullable; import com.mcmoddev.basemetals.BaseMetals; +import com.mcmoddev.basemetals.LegacyMappings; import com.mcmoddev.basemetals.data.MaterialNames; import com.mcmoddev.basemetals.init.ItemGroups; -import com.mcmoddev.basemetals.init.Materials; import com.mcmoddev.basemetals.loot.ContentModeLootCondition; import com.mcmoddev.basemetals.network.ContentModeNetwork; import com.mcmoddev.basemetals.util.EventHandler; -import com.mcmoddev.lib.data.Names; import net.minecraft.block.Block; import net.minecraft.item.Item; +import net.minecraft.util.ResourceLocation; import net.minecraft.world.World; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.RegistryEvent; import net.minecraftforge.fml.common.event.FMLInitializationEvent; import net.minecraftforge.fml.common.event.FMLPostInitializationEvent; import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; +import net.minecraftforge.fml.common.registry.ForgeRegistries; /** Common setup shared by the client and dedicated server. */ public class CommonProxy { @@ -27,29 +28,38 @@ public class CommonProxy { public void preInit(final FMLPreInitializationEvent event) { ContentModeNetwork.init(); ContentModeLootCondition.register(); + LegacyMappings.registerDataFixer(); MinecraftForge.EVENT_BUS.register(com.mcmoddev.basemetals.BaseMetals.class); } /** Maps the old liquid-mercury block ID to the current fluid block. */ public void onRemapBlock(final RegistryEvent.MissingMappings event) { for (final RegistryEvent.MissingMappings.Mapping mapping : event.getAllMappings()) { - if (mapping.key.getNamespace().equals(BaseMetals.MODID) - && (Materials.hasMaterial(MaterialNames.MERCURY)) - && ("liquid_mercury".equals(mapping.key.getPath()))) { - mapping.remap(com.mcmoddev.lib.init.Materials - .getMaterialByName(MaterialNames.MERCURY).getFluidBlock()); + final String name = LegacyMappings.blockTarget(mapping.key.toString()); + if (name != null) { + final ResourceLocation target = new ResourceLocation(name); + if (ForgeRegistries.BLOCKS.containsKey(target)) { + mapping.remap(ForgeRegistries.BLOCKS.getValue(target)); + } else { + BaseMetals.logger.warn("Cannot recover %s: replacement block %s is not registered", + mapping.key, target); + } } } } - /** Maps the old carbon-powder item ID to coal powder. */ + /** Recovers renamed powders, nuggets, fluid items and metal door items. */ public void onRemapItem(final RegistryEvent.MissingMappings event) { for (final RegistryEvent.MissingMappings.Mapping mapping : event.getAllMappings()) { - if (mapping.key.getNamespace().equals(BaseMetals.MODID) - && (Materials.hasMaterial(MaterialNames.COAL)) - && ("carbon_powder".equals(mapping.key.getPath()))) { - mapping.remap(com.mcmoddev.lib.init.Materials.getMaterialByName(MaterialNames.COAL) - .getItem(Names.POWDER)); + final String name = LegacyMappings.itemTarget(mapping.key.toString()); + if (name != null) { + final ResourceLocation target = new ResourceLocation(name); + if (ForgeRegistries.ITEMS.containsKey(target)) { + mapping.remap(ForgeRegistries.ITEMS.getValue(target)); + } else { + BaseMetals.logger.warn("Cannot recover %s: replacement item %s is not registered", + mapping.key, target); + } } } } diff --git a/src/test/java/com/mcmoddev/basemetals/LegacyMappingsTest.java b/src/test/java/com/mcmoddev/basemetals/LegacyMappingsTest.java new file mode 100644 index 000000000..c9d274f85 --- /dev/null +++ b/src/test/java/com/mcmoddev/basemetals/LegacyMappingsTest.java @@ -0,0 +1,50 @@ +package com.mcmoddev.basemetals; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +import net.minecraftforge.event.RegistryEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LegacyMappingsTest { + + @Test + void classRegisteredHandlersMustBeStatic() throws Exception { + for (String name : new String[] { "onRemapBlock", "onRemapItem" }) { + Method method = BaseMetals.class.getMethod(name, RegistryEvent.MissingMappings.class); + assertTrue(Modifier.isStatic(method.getModifiers()), name + " is registered through a class"); + assertTrue(method.isAnnotationPresent(SubscribeEvent.class)); + } + } + + @Test + void recoversTheRenamedMaterialForms() { + assertEquals("basemetals:mercury", LegacyMappings.blockTarget("basemetals:liquid_mercury")); + assertEquals("basemetals:mercury", LegacyMappings.itemTarget("basemetals:liquid_mercury")); + assertEquals("basemetals:coal_powder", LegacyMappings.itemTarget("basemetals:carbon_powder")); + assertEquals("minecraft:iron_nugget", LegacyMappings.itemTarget("basemetals:iron_nugget")); + } + + @Test + void coversEveryHistoricalMetalDoorItem() { + for (String material : new String[] { "adamantine", "aquarium", "brass", "bronze", + "coldiron", "copper", "cupronickel", "electrum", "invar", "lead", "mithril", + "nickel", "platinum", "silver", "starsteel", "steel", "tin" }) { + assertEquals("basemetals:" + material + "_door", + LegacyMappings.itemTarget("basemetals:" + material + "_door_item")); + } + } + + @Test + void leavesCurrentAndForeignNamesAlone() { + for (String name : new String[] { "othermod:carbon_powder", "othermod:steel_door_item", + "basemetals:steel_door", "basemetals:unknown_door_item", "minecraft:iron_nugget" }) { + assertNull(LegacyMappings.itemTarget(name)); + assertNull(LegacyMappings.blockTarget(name)); + } + assertNull(LegacyMappings.blockTarget("basemetals:mercury")); + } +} diff --git a/src/test/java/com/mcmoddev/basemetals/LegacyStackFixTest.java b/src/test/java/com/mcmoddev/basemetals/LegacyStackFixTest.java new file mode 100644 index 000000000..e50b16658 --- /dev/null +++ b/src/test/java/com/mcmoddev/basemetals/LegacyStackFixTest.java @@ -0,0 +1,67 @@ +package com.mcmoddev.basemetals; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LegacyStackFixTest { + @Test + void changesOnlyTheIdentityAndIsIdempotent() { + NBTTagCompound item = stack("basemetals:carbon_powder", 64); + NBTTagCompound tag = new NBTTagCompound(); + tag.setLong("serial", 9876543210L); + tag.setString("custom_name", "Keep this data"); + item.setTag("tag", tag); + item.setByte("Slot", (byte) 9); + NBTTagCompound expected = item.copy(); + expected.setString("id", "basemetals:coal_powder"); + LegacyStackFix fix = new LegacyStackFix(name -> true); + assertSame(item, fix.fixTagCompound(item)); + assertEquals(expected, item); + fix.fixTagCompound(item); + assertEquals(expected, item); + } + + @Test + void visitsNestedInventoriesWithoutChangingTheirContainerFormat() { + NBTTagCompound chunk = new NBTTagCompound(); + NBTTagCompound machine = new NBTTagCompound(); + machine.setString("id", "example:machine"); + NBTTagList inventory = new NBTTagList(); + inventory.appendTag(stack("basemetals:bronze_door_item", 5)); + machine.setTag("customInventory", inventory); + NBTTagList tiles = new NBTTagList(); + tiles.appendTag(machine); + chunk.setTag("TileEntities", tiles); + new LegacyStackFix(name -> true).fixTagCompound(chunk); + assertEquals("example:machine", machine.getString("id")); + assertEquals("basemetals:bronze_door", inventory.getCompoundTagAt(0).getString("id")); + assertEquals(5, inventory.getCompoundTagAt(0).getByte("Count")); + assertTrue(machine.hasKey("customInventory")); + } + + @Test + void leavesUnrelatedDataAndUnavailableTargetsAlone() { + NBTTagCompound unavailable = stack("basemetals:carbon_powder", 12); + NBTTagCompound expected = unavailable.copy(); + new LegacyStackFix(name -> false).fixTagCompound(unavailable); + assertEquals(expected, unavailable); + NBTTagCompound label = new NBTTagCompound(); + label.setString("id", "basemetals:carbon_powder"); + new LegacyStackFix(name -> true).fixTagCompound(label); + assertEquals("basemetals:carbon_powder", label.getString("id")); + NBTTagCompound foreign = stack("othermod:carbon_powder", 12); + new LegacyStackFix(name -> true).fixTagCompound(foreign); + assertEquals("othermod:carbon_powder", foreign.getString("id")); + } + + private static NBTTagCompound stack(final String id, final int count) { + NBTTagCompound stack = new NBTTagCompound(); + stack.setString("id", id); + stack.setByte("Count", (byte) count); + stack.setShort("Damage", (short) 3); + return stack; + } +} diff --git a/src/test/java/com/mcmoddev/basemetals/test/BaseMetalsLegacyRuntimeProbe.java b/src/test/java/com/mcmoddev/basemetals/test/BaseMetalsLegacyRuntimeProbe.java new file mode 100644 index 000000000..119c3568f --- /dev/null +++ b/src/test/java/com/mcmoddev/basemetals/test/BaseMetalsLegacyRuntimeProbe.java @@ -0,0 +1,187 @@ +package com.mcmoddev.basemetals.test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.mcmoddev.basemetals.LegacyMappings; +import net.minecraft.block.Block; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.datafix.FixTypes; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.storage.ThreadedFileIOBase; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.RegistryEvent; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLServerStartedEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +import net.minecraftforge.fml.common.registry.ForgeRegistries; + +/** Loads the indexed content in a disposable legacy save and checks item recovery. */ +@Mod(modid = "basemetals_legacy_probe", name = "Base Metals Legacy Runtime Probe", version = "1", + dependencies = "required-after:basemetals", acceptedMinecraftVersions = "[1.12.2]") +public final class BaseMetalsLegacyRuntimeProbe { + private MinecraftServer server; + private final List chunks = new ArrayList<>(); + private int next; + private boolean ciFixture; + + @Mod.EventHandler + public void started(final FMLServerStartedEvent event) throws Exception { + server = FMLCommonHandler.instance().getMinecraftServerInstance(); + ciFixture = Boolean.getBoolean("basemetals.legacyProbe.ciFixture"); + if (ciFixture) { + MinecraftForge.EVENT_BUS.register(this); + return; + } + + for (String line : Files.readAllLines(Paths.get(required("basemetals.legacyProbe.index")), + StandardCharsets.UTF_8)) { + String[] values = line.split(","); + chunks.add(new int[] { Integer.parseInt(values[0]), Integer.parseInt(values[1]), + Integer.parseInt(values[2]) }); + } + if (chunks.isEmpty()) throw new AssertionError("No source-content chunks were indexed"); + verifyMappingsAndStacks(); + for (WorldServer world : server.worlds) { + world.getGameRules().setOrCreateGameRule("randomTickSpeed", "0"); + world.getGameRules().setOrCreateGameRule("doFireTick", "false"); + } + MinecraftForge.EVENT_BUS.register(this); + System.out.println("BASE_METALS_LEGACY_INDEX chunks=" + chunks.size()); + } + + private void verifyMappingsAndStacks() { + RegistryEvent.MissingMappings.Mapping fluid = new RegistryEvent.MissingMappings.Mapping<>( + ForgeRegistries.BLOCKS, ForgeRegistries.BLOCKS, + new ResourceLocation("basemetals:liquid_mercury"), 218); + MinecraftForge.EVENT_BUS.post(new RegistryEvent.MissingMappings<>( + new ResourceLocation("minecraft:blocks"), ForgeRegistries.BLOCKS, + Collections.singletonList(fluid))); + require(fluid.getAction() == RegistryEvent.MissingMappings.Action.REMAP, "Mercury handler was not called"); + require(fluid.getTarget() == ForgeRegistries.BLOCKS.getValue(new ResourceLocation("basemetals:mercury")), + "Wrong mercury replacement"); + List names = new ArrayList<>(); + Collections.addAll(names, "basemetals:liquid_mercury", "basemetals:carbon_powder", "basemetals:iron_nugget"); + for (String material : new String[] { "adamantine", "aquarium", "brass", "bronze", "coldiron", + "copper", "cupronickel", "electrum", "invar", "lead", "mithril", "nickel", "platinum", + "silver", "starsteel", "steel", "tin" }) names.add("basemetals:" + material + "_door_item"); + int checked = 0; + for (String name : names) { + ResourceLocation target = new ResourceLocation(LegacyMappings.itemTarget(name)); + RegistryEvent.MissingMappings.Mapping mapping = new RegistryEvent.MissingMappings.Mapping<>( + ForgeRegistries.ITEMS, ForgeRegistries.ITEMS, new ResourceLocation(name), 4203 + checked); + MinecraftForge.EVENT_BUS.post(new RegistryEvent.MissingMappings<>(new ResourceLocation("minecraft:items"), + ForgeRegistries.ITEMS, Collections.singletonList(mapping))); + require(mapping.getAction() == RegistryEvent.MissingMappings.Action.REMAP, "Item handler missed " + name); + require(mapping.getTarget() == ForgeRegistries.ITEMS.getValue(target), "Wrong target for " + name); + NBTTagCompound saved = new NBTTagCompound(); + saved.setString("id", name); + saved.setByte("Count", (byte) 49); + saved.setShort("Damage", (short) 3); + NBTTagCompound custom = new NBTTagCompound(); + custom.setString("legacy_note", "preserve this item data"); + custom.setLong("serial", 1234567890123L); + NBTTagCompound display = new NBTTagCompound(); + display.setString("Name", "Legacy material"); + custom.setTag("display", display); + NBTTagCompound enchantment = new NBTTagCompound(); + enchantment.setShort("id", (short) 34); + enchantment.setShort("lvl", (short) 3); + NBTTagList enchantments = new NBTTagList(); + enchantments.appendTag(enchantment); + custom.setTag("ench", enchantments); + saved.setTag("tag", custom); + NBTTagCompound fixed = FMLCommonHandler.instance().getDataFixer().process(FixTypes.ITEM_INSTANCE, saved); + require(target.toString().equals(fixed.getString("id")), "Raw stack fixer missed " + name); + ItemStack recovered = new ItemStack(fixed); + require(!recovered.isEmpty() && recovered.getItem() == mapping.getTarget(), "Saved alias failed: " + name); + require(recovered.getCount() == 49 && recovered.getItemDamage() == 3, "Count or damage changed: " + name); + require(custom.equals(recovered.getTagCompound()), "Custom NBT changed: " + name); + ItemStack roundTrip = new ItemStack(recovered.writeToNBT(new NBTTagCompound())); + require(ItemStack.areItemStacksEqual(recovered, roundTrip), "Reload changed " + name); + checked++; + } + System.out.println("BASE_METALS_LEGACY_MAPPING_PASS block=1 items=" + checked + " nbt_roundtrips=" + checked); + } + + @SubscribeEvent + public void tick(final TickEvent.ServerTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + try { + if (ciFixture) { + verifyCiFixture(); + MinecraftForge.EVENT_BUS.unregister(this); + server.initiateShutdown(); + return; + } + + for (int i = 0; i < 4 && next < chunks.size(); i++) { + int[] point = chunks.get(next++); + WorldServer world = server.getWorld(point[0]); + Chunk chunk = world.getChunk(point[1], point[2]); + world.getChunkProvider().queueUnload(chunk); + } + if (next == chunks.size()) { + ThreadedFileIOBase.getThreadedIOInstance().waitForFinish(); + String phase = required("basemetals.legacyProbe.phase"); + Files.write(new File(required("basemetals.legacyProbe.marker")).toPath(), + Collections.singletonList("phase=" + phase + " chunks=" + next), StandardCharsets.UTF_8); + System.out.println("BASE_METALS_LEGACY_WORLD_PASS phase=" + phase + " chunks=" + next); + MinecraftForge.EVENT_BUS.unregister(this); + server.initiateShutdown(); + } + } catch (Throwable failure) { + failure.printStackTrace(); + FMLCommonHandler.instance().exitJava(2, true); + } + } + + private void verifyCiFixture() throws Exception { + WorldServer world = server.getWorld(0); + String phase = required("basemetals.legacyProbe.phase"); + File marker = new File(required("basemetals.legacyProbe.marker")); + world.getGameRules().setOrCreateGameRule("randomTickSpeed", "0"); + world.getGameRules().setOrCreateGameRule("doFireTick", "false"); + world.getGameRules().setOrCreateGameRule("doMobSpawning", "false"); + + if ("seed".equals(phase)) { + LegacySaveFixture.seed(world, marker.getParentFile()); + } else { + require(world.getChunkProvider().getLoadedChunk(LegacySaveFixture.LATER_CHUNK, + LegacySaveFixture.LATER_CHUNK) == null, "Distant chunk loaded before the test visited it"); + LegacySaveFixture.verifyLoaded(world, 0); + + if ("delayed".equals(phase) || "stable".equals(phase)) { + LegacySaveFixture.verifyLoaded(world, LegacySaveFixture.LATER_CHUNK); + } + } + + server.saveAllWorlds(false); + ThreadedFileIOBase.getThreadedIOInstance().waitForFinish(); + Files.write(marker.toPath(), ("phase=" + phase + "\npassed=true\n").getBytes(StandardCharsets.UTF_8)); + System.out.println("BASE_METALS_LEGACY_RUNTIME_PASS phase=" + phase); + } + + private static String required(final String key) { + String value = System.getProperty(key); + if (value == null) throw new AssertionError("Missing property " + key); + return value; + } + + private static void require(final boolean passed, final String message) { + if (!passed) throw new AssertionError(message); + } +} diff --git a/src/test/java/com/mcmoddev/basemetals/test/BaseMetalsRuntimeProbe.java b/src/test/java/com/mcmoddev/basemetals/test/BaseMetalsRuntimeProbe.java index 7836ae52e..ad470cb6e 100644 --- a/src/test/java/com/mcmoddev/basemetals/test/BaseMetalsRuntimeProbe.java +++ b/src/test/java/com/mcmoddev/basemetals/test/BaseMetalsRuntimeProbe.java @@ -145,7 +145,7 @@ public void serverStarted(final FMLServerStartedEvent event) throws IOException final String mmdLibVersion = version("mmdlib"); final String oreSpawnVersion = version("orespawn"); - require("2.6.0.112021".equals(baseMetalsVersion), + require("2.6.1.112021".equals(baseMetalsVersion), "Unexpected Base Metals version " + baseMetalsVersion); // MMDLib rc2.36 still reports "rc2" in its mod metadata. require("1.0.0-rc2".equals(mmdLibVersion), diff --git a/src/test/java/com/mcmoddev/basemetals/test/LegacySaveFixture.java b/src/test/java/com/mcmoddev/basemetals/test/LegacySaveFixture.java new file mode 100644 index 000000000..b41d4b1f8 --- /dev/null +++ b/src/test/java/com/mcmoddev/basemetals/test/LegacySaveFixture.java @@ -0,0 +1,328 @@ +package com.mcmoddev.basemetals.test; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +import net.minecraft.block.Block; +import net.minecraft.init.Blocks; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.CompressedStreamTools; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.tileentity.TileEntityChest; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.storage.RegionFile; +import net.minecraftforge.fml.common.registry.ForgeRegistries; + +/** A tiny Anvil save with old registry names and a chest outside the spawn area. */ +public final class LegacySaveFixture { + + private static final String SENTINEL = "Base Metals disposable legacy-save fixture\n"; + static final int LATER_CHUNK = 64; + static final Map ITEMS = itemNames(); + + private LegacySaveFixture() { + } + + private static Map itemNames() { + Map names = new LinkedHashMap<>(); + names.put("basemetals:liquid_mercury", "basemetals:mercury"); + names.put("basemetals:carbon_powder", "basemetals:coal_powder"); + names.put("basemetals:iron_nugget", "minecraft:iron_nugget"); + + for (String material : new String[] { "adamantine", "aquarium", "brass", "bronze", "coldiron", + "copper", "cupronickel", "electrum", "invar", "lead", "mithril", "nickel", "platinum", + "silver", "starsteel", "steel", "tin" }) { + names.put("basemetals:" + material + "_door_item", "basemetals:" + material + "_door"); + } + + return names; + } + + static NBTTagCompound stack(final String id, final int slot) { + NBTTagCompound item = new NBTTagCompound(); + item.setString("id", id); + item.setByte("Slot", (byte) slot); + item.setByte("Count", (byte) 49); + item.setShort("Damage", (short) 3); + + NBTTagCompound data = new NBTTagCompound(); + data.setLong("serial", 1234567890123L + slot); + data.setString("legacy_note", "Keep this item data"); + NBTTagCompound display = new NBTTagCompound(); + display.setString("Name", "Legacy material " + slot); + data.setTag("display", display); + NBTTagCompound enchantment = new NBTTagCompound(); + enchantment.setShort("id", (short) 34); + enchantment.setShort("lvl", (short) 3); + NBTTagList enchantments = new NBTTagList(); + enchantments.appendTag(enchantment); + data.setTag("ench", enchantments); + item.setTag("tag", data); + + return item; + } + + static void seed(final WorldServer world, final File runDirectory) throws Exception { + world.setSpawnPoint(new BlockPos(0, 81, 0)); + + for (int chunk : new int[] { 0, LATER_CHUNK }) { + BlockPos chestPosition = chestPosition(chunk); + world.getChunk(chunk, chunk); + world.setBlockState(chestPosition, Blocks.CHEST.getDefaultState(), 2); + TileEntityChest chest = (TileEntityChest) world.getTileEntity(chestPosition); + require(chest != null, "Fixture chest was not created"); + int slot = 0; + + for (String current : ITEMS.values()) { + Item item = ForgeRegistries.ITEMS.getValue(new ResourceLocation(current)); + require(item != null, "Fixture item is not registered: " + current); + chest.setInventorySlotContents(slot, new ItemStack(stack(current, slot))); + slot++; + } + + chest.setInventorySlotContents(slot, new ItemStack(stack("minecraft:diamond", slot))); + chest.markDirty(); + + // Enclose the source block so fluid ticks cannot change the fixture. + BlockPos mercuryPosition = chestPosition.add(3, 0, 3); + for (EnumFacing side : EnumFacing.values()) { + world.setBlockState(mercuryPosition.offset(side), Blocks.STONE.getDefaultState(), 2); + } + + Block mercury = ForgeRegistries.BLOCKS.getValue(new ResourceLocation("basemetals:mercury")); + require(mercury != null, "Mercury block is not registered"); + world.setBlockState(mercuryPosition, mercury.getDefaultState(), 2); + } + + Files.write(new File(runDirectory, "legacy-fixture.txt").toPath(), SENTINEL.getBytes(StandardCharsets.UTF_8)); + } + + static void verifyLoaded(final WorldServer world, final int chunk) { + world.getChunk(chunk, chunk); + BlockPos position = chestPosition(chunk); + require(world.getTileEntity(position) instanceof TileEntityChest, "Missing chest in chunk " + chunk); + TileEntityChest chest = (TileEntityChest) world.getTileEntity(position); + int slot = 0; + + for (String current : ITEMS.values()) { + verifyStack(chest.getStackInSlot(slot).writeToNBT(new NBTTagCompound()), current, slot); + slot++; + } + + verifyStack(chest.getStackInSlot(slot).writeToNBT(new NBTTagCompound()), "minecraft:diamond", slot); + require(new ResourceLocation("basemetals:mercury").equals( + world.getBlockState(position.add(3, 0, 3)).getBlock().getRegistryName()), + "Placed Mercury changed in chunk " + chunk); + + // Loading alone need not save a chunk. Force a write to test the next restart. + world.getChunk(chunk, chunk).markDirty(); + } + + private static BlockPos chestPosition(final int chunk) { + return new BlockPos(chunk * 16, 80, chunk * 16); + } + + private static void verifyStack(final NBTTagCompound actual, final String id, final int slot) { + NBTTagCompound expected = stack(id, slot); + require(id.equals(actual.getString("id")), "Wrong item in slot " + slot + ": " + actual); + require(actual.getByte("Count") == 49, "Item count changed in slot " + slot); + require(actual.getShort("Damage") == 3, "Item damage changed in slot " + slot); + require(expected.getCompoundTag("tag").equals(actual.getCompoundTag("tag")), + "Custom name, enchantment or other NBT changed in slot " + slot); + } + + /** Runs between server boots, when no region files are open. */ + public static void main(final String[] arguments) throws Exception { + require(arguments.length == 2, "Expected an operation and fixture directory"); + File run = new File(arguments[1]).getCanonicalFile(); + File sentinel = new File(run, "legacy-fixture.txt"); + require(sentinel.isFile() && SENTINEL.equals(new String(Files.readAllBytes(sentinel.toPath()), + StandardCharsets.UTF_8)), "Refusing to edit a save not created by the fixture probe"); + + if ("prepare".equals(arguments[0])) { + prepare(run); + } else { + verifySaved(run, arguments[0]); + } + } + + private static void prepare(final File run) throws Exception { + File levelFile = new File(run, "world/level.dat"); + NBTTagCompound level; + try (FileInputStream input = new FileInputStream(levelFile)) { + level = CompressedStreamTools.readCompressed(input); + } + + NBTTagCompound registries = level.getCompoundTag("FML").getCompoundTag("Registries"); + renameRegistry(registries.getCompoundTag("minecraft:items").getTagList("ids", 10), ITEMS); + Map blocks = new LinkedHashMap<>(); + blocks.put("basemetals:liquid_mercury", "basemetals:mercury"); + renameRegistry(registries.getCompoundTag("minecraft:blocks").getTagList("ids", 10), blocks); + makeLegacy(level.getCompoundTag("Data")); + + try (FileOutputStream output = new FileOutputStream(levelFile)) { + CompressedStreamTools.writeCompressed(level, output); + } + + for (int chunk : new int[] { 0, LATER_CHUNK }) { + NBTTagCompound data = readChunk(run, chunk); + NBTTagList items = chestItems(data, chunk); + require(items.tagCount() == 21, "Incomplete seed chest"); + int slot = 0; + + for (Map.Entry name : ITEMS.entrySet()) { + NBTTagCompound item = items.getCompoundTagAt(slot); + verifyStack(item, name.getValue(), slot); + item.setString("id", name.getKey()); + slot++; + } + + makeLegacy(data); + writeChunk(run, chunk, data); + } + + Properties baseline = new Properties(); + baseline.setProperty("deferredRegionSha256", hash(regionFile(run, LATER_CHUNK))); + try (FileOutputStream output = new FileOutputStream(new File(run, "legacy-baseline.properties"))) { + baseline.store(output, "Unopened legacy chunk"); + } + + System.out.println("BASE_METALS_LEGACY_FIXTURE_READY aliases=20 blocks=1 chunks=2"); + } + + static void renameRegistry(final NBTTagList entries, final Map names) { + for (Map.Entry name : names.entrySet()) { + int matches = 0; + for (int i = 0; i < entries.tagCount(); i++) { + NBTTagCompound entry = entries.getCompoundTagAt(i); + if (name.getValue().equals(entry.getString("K"))) { + entry.setString("K", name.getKey()); + matches++; + } + } + + require(matches == 1, "Expected one registry entry for " + name.getValue() + ", found " + matches); + } + } + + private static void makeLegacy(final NBTTagCompound root) { + root.setInteger("DataVersion", 512); + root.getCompoundTag("ForgeDataVersion").removeTag("basemetals"); + root.getCompoundTag("ForgeDataVersion").setInteger("minecraft", 512); + } + + private static void verifySaved(final File run, final String phase) throws Exception { + require("first".equals(phase) || "reload".equals(phase) || "delayed".equals(phase) + || "stable".equals(phase), "Unknown fixture phase: " + phase); + verifyCanonicalChunk(run, 0); + boolean deferred = "first".equals(phase) || "reload".equals(phase); + + if (deferred) { + Properties baseline = new Properties(); + try (FileInputStream input = new FileInputStream(new File(run, "legacy-baseline.properties"))) { + baseline.load(input); + } + + require(hash(regionFile(run, LATER_CHUNK)).equals(baseline.getProperty("deferredRegionSha256")), + "An unopened region was changed during " + phase); + NBTTagCompound data = readChunk(run, LATER_CHUNK); + require(!data.getCompoundTag("ForgeDataVersion").hasKey("basemetals"), + "Unopened chunk was marked migrated"); + NBTTagList items = chestItems(data, LATER_CHUNK); + int slot = 0; + for (String legacy : ITEMS.keySet()) { + verifyStack(items.getCompoundTagAt(slot), legacy, slot++); + } + } else { + verifyCanonicalChunk(run, LATER_CHUNK); + } + + Files.write(new File(run, phase + "-save.properties").toPath(), + ("phase=" + phase + "\npassed=true\ndeferredUnchanged=" + deferred + "\n") + .getBytes(StandardCharsets.UTF_8)); + System.out.println("BASE_METALS_LEGACY_SAVE_PASS phase=" + phase + " deferredUnchanged=" + deferred); + } + + private static void verifyCanonicalChunk(final File run, final int chunk) throws Exception { + NBTTagCompound data = readChunk(run, chunk); + require(data.getCompoundTag("ForgeDataVersion").getInteger("basemetals") == 1, + "Chunk " + chunk + " has no Base Metals migration stamp"); + NBTTagList items = chestItems(data, chunk); + require(items.tagCount() == 21, "Saved chest lost items in chunk " + chunk); + int slot = 0; + + for (String current : ITEMS.values()) { + verifyStack(items.getCompoundTagAt(slot), current, slot++); + } + + verifyStack(items.getCompoundTagAt(slot), "minecraft:diamond", slot); + } + + private static NBTTagList chestItems(final NBTTagCompound data, final int chunk) { + NBTTagList tiles = data.getCompoundTag("Level").getTagList("TileEntities", 10); + BlockPos position = chestPosition(chunk); + for (int i = 0; i < tiles.tagCount(); i++) { + NBTTagCompound tile = tiles.getCompoundTagAt(i); + if (tile.getInteger("x") == position.getX() && tile.getInteger("y") == position.getY() + && tile.getInteger("z") == position.getZ()) { + return tile.getTagList("Items", 10); + } + } + + throw new AssertionError("No saved chest in chunk " + chunk); + } + + private static File regionFile(final File run, final int chunk) { + return new File(run, "world/region/r." + (chunk >> 5) + "." + (chunk >> 5) + ".mca"); + } + + private static NBTTagCompound readChunk(final File run, final int chunk) throws Exception { + File path = regionFile(run, chunk); + require(path.isFile(), "Missing fixture region: " + path); + RegionFile region = new RegionFile(path); + try (DataInputStream input = region.getChunkDataInputStream(chunk & 31, chunk & 31)) { + require(input != null, "Missing fixture chunk: " + chunk); + return CompressedStreamTools.read(input); + } finally { + region.close(); + } + } + + private static void writeChunk(final File run, final int chunk, final NBTTagCompound data) throws Exception { + RegionFile region = new RegionFile(regionFile(run, chunk)); + try (DataOutputStream output = region.getChunkDataOutputStream(chunk & 31, chunk & 31)) { + CompressedStreamTools.write(data, output); + } finally { + region.close(); + } + } + + private static String hash(final File file) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file.toPath())); + StringBuilder result = new StringBuilder(); + for (byte value : digest) { + result.append(String.format("%02x", value & 255)); + } + return result.toString(); + } + + static void require(final boolean condition, final String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/src/test/java/com/mcmoddev/basemetals/test/LegacySaveFixtureTest.java b/src/test/java/com/mcmoddev/basemetals/test/LegacySaveFixtureTest.java new file mode 100644 index 000000000..d20ea1a73 --- /dev/null +++ b/src/test/java/com/mcmoddev/basemetals/test/LegacySaveFixtureTest.java @@ -0,0 +1,61 @@ +package com.mcmoddev.basemetals.test; + +import java.nio.file.Path; +import java.util.Collections; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LegacySaveFixtureTest { + + @TempDir + Path directory; + + @Test + void refusesDirectoriesNotCreatedByTheProbe() { + assertThrows(AssertionError.class, + () -> LegacySaveFixture.main(new String[] { "prepare", directory.toString() })); + assertEquals(0, directory.toFile().list().length); + } + + @Test + void renamesRegistryKeysWithoutChangingNumericIdsOrOtherEntries() { + NBTTagList entries = new NBTTagList(); + NBTTagCompound mercury = entry("basemetals:mercury", 218); + NBTTagCompound foreign = entry("othermod:mercury", 500); + entries.appendTag(mercury); + entries.appendTag(foreign); + + LegacySaveFixture.renameRegistry(entries, + Collections.singletonMap("basemetals:liquid_mercury", "basemetals:mercury")); + + assertEquals("basemetals:liquid_mercury", mercury.getString("K")); + assertEquals(218, mercury.getInteger("V")); + assertEquals("othermod:mercury", foreign.getString("K")); + assertEquals(500, foreign.getInteger("V")); + } + + @Test + void rejectsMissingOrAmbiguousRegistryEntries() { + NBTTagList entries = new NBTTagList(); + assertThrows(AssertionError.class, () -> LegacySaveFixture.renameRegistry(entries, + Collections.singletonMap("old:name", "new:name"))); + + entries.appendTag(entry("new:name", 1)); + entries.appendTag(entry("new:name", 2)); + assertThrows(AssertionError.class, () -> LegacySaveFixture.renameRegistry(entries, + Collections.singletonMap("old:name", "new:name"))); + } + + private static NBTTagCompound entry(final String key, final int value) { + NBTTagCompound entry = new NBTTagCompound(); + entry.setString("K", key); + entry.setInteger("V", value); + return entry; + } +}