feat(anvil): let a loader migrate the chunks it reads - #50
Merged
Conversation
A world older than the running server loses whatever the server no longer knows by name. Measured on a real 52 GB world: `minecraft:grass` and `minecraft:chain` are gone from the 26.1.2 registry, so 260856 blocks decode to air through the UnknownEntryPolicy, with one log line each for the whole world. The loader then stamps the current DataVersion onto every chunk it saves, so the loss is written back and the world afterwards claims to be current. ChunkMigrationMode names the three answers. OFF is the default and is exactly what the loader did before. IN_MEMORY translates on every read and never touches the world. ON_DISK writes the result back, so the work happens once per chunk instead of once per load. The seam is a classpath service because the dependency only runs one way: falco-migration depends on this module, so this module cannot depend on it back. A deployment that registers no migrator carries no migration code, and one that selects a mode without an engine fails to build rather than migrating nothing. Two decisions worth stating: Migration runs BEFORE the version guard. The guard refuses a chunk below the floor and one in the pre-1.18 Level layout, and migrating is what turns such a chunk into one it accepts. The other order would reject every world the option exists to rescue. The backup cannot be switched off. There is a slot for where it goes and none for skipping it: ON_DISK replaces stored chunks, a wrong rule is only found afterwards, and by then the original is the only way back. It is copied per region file just before that file is first written, through a .partial name and an atomic move, and it lands beside the region directory rather than inside it — a copy inside would be read back as world data by the loader it was taken to protect. byteLayerKnowsNoNbt gains ChunkMigrator: it takes and returns a CompoundBinaryTag, the same shape as ChunkVersionPolicy, so it is a member of that layer rather than an exception to it. ChunkMigrationMode names no NBT type and deliberately gets no entry. Evidence: eleven tests against real region files, each checked by injecting the defect it exists to catch — migration moved behind the guard, IN_MEMORY writing to disk, the backup skipped, and an existing backup overwritten. Each was caught by its own test and by no other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR
FalcoChunkMigrator is an adapter and holds no rules of its own. It translates the engine's two edges into the shapes the loader's ChunkMigrator contract asks for: which versions ChunkMigration accepts, and which exception it throws. The floor is real and the ceiling is not. Below MINIMUM_SOURCE_VERSION a chunk predates the flattening and holds numeric block ids the engine's types do not speak; above it there is no limit, so a 1.20 world can be lifted to a current server. A chunk newer than the target is declined for being not older, not by a bound of the engine. MigrationException is unchecked and belongs to this module, while the loader speaks ChunkDataException. Translating here rather than leaving it to the loader keeps the failure specific: unchecked, it would land in the loader's generic RuntimeException handler and be reported as an unspecified defect instead of as this chunk's data being unconvertible. The original is kept as the cause. The registration sits on a dedicated adapter rather than on ChunkMigration so the engine stays usable without a loader, which is what the command line tool will need. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR
The second of the two names a real 52 GB world holds that the 26.1.2 registry no longer knows, and the one that had no rule: 18248 blocks that decode to air. The version is measured, not looked up. The two wiki-sourced numbers already in this file were each wrong on the first attempt in the same release-vs-snapshot way, so this one comes from the world itself: 1399 nether region files scanned, every chunk's block names correlated with that same chunk's stored DataVersion. "chain" appears at 3465, 3578, 3955 and 4435 (4930 chunks); "iron_chain" only at 4556 (189 chunks); no chunk carries both. The change therefore happened in (4435, 4556], and that world holds nothing from between those versions, so the data cannot resolve it further. 4556 is the upper bound and the safe end to pick. Too high only lets the rule inspect chunks that no longer contain the old name, where its predicate does not match and nothing happens. Too low would leave "chain" standing in every chunk between the true version and the chosen one, and a name the server does not know is what the loader silently turns into air. The boundary test asserts with the two measured versions rather than round numbers, so moving since() off its evidence fails it. A second test pins that the rule leaves iron_chain and copper_chain alone: a Paper world holds both forms side by side, because Paper converts what it loads, and a rule that also rewrote the target would convert the converted half again. With this rule the world that reported two unknown blocks reports none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR
The world path was wired to falco-demo/world, so the demo could only ever measure the one world checked into the repository. Verifying the migration modes against real pre-current chunks meant pointing it somewhere else, and there was no way to. -Pworld=<path> now overrides it; without the property nothing changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR
Contributor
Test results 330 files 330 suites 9m 56s ⏱️ Results for commit 0179350. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wires the
falco-migrationengine into the loader, and closes the one block rename it was missing.Why
A world older than the running server loses whatever the server no longer knows by name — silently. Measured against a real 52 GB world (8299 region files, both dimensions), all 991 block names checked against the 26.1.2 registry:
minecraft:grass→short_grassminecraft:chain→iron_chain989 of 991 names are fine. Those two decode to air through the
UnknownEntryPolicy, with one log line each for the entire world — 260856 blocks. The loader then stamps the current DataVersion onto every chunk it saves, so the loss is written back and the world afterwards claims to be current.The world holds both forms side by side —
grassandshort_grass,chainandiron_chain— because Paper converts what it loads. What was never loaded since the update is still in the old form.What this adds
ChunkMigrationModenames three answers:OFF— the default, and exactly what the loader did beforeIN_MEMORY— translates on every read, never touches the worldON_DISK— writes the result back, so the work happens once per chunk instead of once per loadA log line on startup states which mode is on and what it costs; both non-default modes are invisible from the outside otherwise.
Two decisions worth reviewing
Migration runs before the version guard. The guard refuses a chunk below the floor and one in the pre-1.18
Levellayout, and migrating is what turns such a chunk into one it accepts. The other order would reject every world the option exists to rescue.The backup cannot be switched off. There is a slot for where it goes and none for skipping it.
ON_DISKreplaces stored chunks, a wrong rule is only found afterwards, and by then the original is the only way back. It is copied per region file just before that file is first written, through a.partialname and an atomic move, and it lands beside the region directory — a copy inside would be read back as world data by the loader it was taken to protect.The seam is a classpath service because the dependency only runs one way:
falco-migrationdepends onfalco-anvil, so the reverse is impossible. A deployment that registers no migrator carries no migration code; one that selects a mode without an engine fails to build rather than migrating nothing.The chain rule's version is measured, not looked up
The two wiki-sourced numbers already in
BlockStateRuleswere each wrong on the first attempt in the same release-vs-snapshot way, so this one comes from the world: every chunk's block names correlated with that same chunk's stored DataVersion, across 1399 region files.chainappears at 3465, 3578, 3955 and 4435 (4930 chunks).iron_chainonly at 4556 (189 chunks). No chunk carries both. The change happened in (4435, 4556] and the data cannot resolve it further.4556 is the upper bound and the safe end: too high only lets the rule inspect chunks that no longer contain the old name, where nothing happens; too low would leave
chainstanding between the true version and the chosen one, and an unknown name is what becomes air.Evidence
Against real 1.20.1 chunks (2219 chunks from the survival world):
OFFIN_MEMORYON_DISK1stON_DISK2ndgrass,chainThe second
ON_DISKrun finding nothing to do is what shows the write-back was correct. The backup held exactly the four region files actually touched.19 tests, each checked by injecting the defect it exists to catch:
testMigrationRunsBeforeTheVersionGuardIN_MEMORYwriting to disktestInMemoryMigratesTheChunkAndLeavesTheFileUntouchedtestOnDiskCopiesTheOriginalBeforeItWritestestAnExistingBackupIsNotOverwrittenByALaterRunMETA-INF/servicesremovedtestTheAdapterIsRegisteredAsAServicesince()moved off its measurementtestChainIsRenamedToIronChainAtTheMeasuredBoundaryEach was caught by its own test and by no other.
byteLayerKnowsNoNbtgainsChunkMigrator: it takes and returns aCompoundBinaryTag, the same shape asChunkVersionPolicy, so it belongs to that layer rather than being an exception to it.ChunkMigrationModenames no NBT type and deliberately gets no entry../gradlew check --rerun-tasksis green across all modules.Not in this PR
No downgrade, no Bedrock, no CLI. The engine's floor stays at 1.13.
🤖 Generated with Claude Code
https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR