Skip to content

feat(anvil): let a loader migrate the chunks it reads - #50

Merged
TheMeinerLP merged 4 commits into
mainfrom
feat/loader-migration
Aug 5, 2026
Merged

feat(anvil): let a loader migrate the chunks it reads#50
TheMeinerLP merged 4 commits into
mainfrom
feat/loader-migration

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Wires the falco-migration engine 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:

Block occurrences rule
minecraft:grassshort_grass 241708 already present (3693)
minecraft:chainiron_chain 18248 added here

989 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 sidegrass and short_grass, chain and iron_chain — because Paper converts what it loads. What was never loaded since the update is still in the old form.

What this adds

ChunkMigrationMode names three answers:

  • OFF — the default, and exactly what the loader did before
  • IN_MEMORY — translates on every read, never touches the world
  • ON_DISK — writes the result back, so the work happens once per chunk instead of once per load
FalcoAnvilLoader.builder()
    .migration(ChunkMigrationMode.IN_MEMORY)
    .build(worldRoot, dimension);

A 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 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 — 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-migration depends on falco-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 BlockStateRules were 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.

chain appears at 3465, 3578, 3955 and 4435 (4930 chunks). iron_chain only 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 chain standing 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):

OFF IN_MEMORY ON_DISK 1st ON_DISK 2nd
unknown blocks grass, chain none none none
migrated 0 3000 3000 0
errors 0 0 0 0

The second ON_DISK run 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:

injected defect caught by
migration moved behind the guard testMigrationRunsBeforeTheVersionGuard
IN_MEMORY writing to disk testInMemoryMigratesTheChunkAndLeavesTheFileUntouched
backup skipped before writing testOnDiskCopiesTheOriginalBeforeItWrites
existing backup overwritten testAnExistingBackupIsNotOverwrittenByALaterRun
META-INF/services removed testTheAdapterIsRegisteredAsAService
since() moved off its measurement testChainIsRenamedToIronChainAtTheMeasuredBoundary

Each was caught by its own test and by no other.

byteLayerKnowsNoNbt gains ChunkMigrator: it takes and returns a CompoundBinaryTag, the same shape as ChunkVersionPolicy, so it belongs to that layer rather than being an exception to it. ChunkMigrationMode names no NBT type and deliberately gets no entry.

./gradlew check --rerun-tasks is 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

TheMeinerLP and others added 4 commits August 5, 2026 22:16
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
@TheMeinerLP
TheMeinerLP requested a review from a team as a code owner August 5, 2026 20:18
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Test results

  330 files    330 suites   9m 56s ⏱️
1 068 tests 1 067 ✅ 1 💤 0 ❌
3 231 runs  3 229 ✅ 2 💤 0 ❌

Results for commit 0179350.

@TheMeinerLP
TheMeinerLP merged commit e6eefd0 into main Aug 5, 2026
8 checks passed
@TheMeinerLP
TheMeinerLP deleted the feat/loader-migration branch August 5, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant