Skip to content

fix(anvil)!: refuse a world the loader cannot read instead of returning air - #45

Merged
TheMeinerLP merged 12 commits into
mainfrom
feat/anvil-version-guard
Aug 4, 2026
Merged

fix(anvil)!: refuse a world the loader cannot read instead of returning air#45
TheMeinerLP merged 12 commits into
mainfrom
feat/anvil-version-guard

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Proposed changes

FalcoAnvilLoader expects the chunk layout Minecraft writes since snapshot 21w43a: sections on the
root compound. A world written before that keeps everything one level down, under Level. Three
decisions, each defensible on its own, combined into silent data loss:

  1. chunkStatus looks for Status/status on the root (:1354-1357). A pre-21w43a chunk carries
    Level.Status, so the answer is null.
  2. isFullyGenerated(null) returns true (:1371-1372) — deliberately, so a world written by a tool
    that stores no status stays readable.
  3. NbtReads.optionalList returns ListBinaryTag.empty() for a missing key (NbtReads.java:162-167),
    and the pre-21w43a key is Level.Sections.

The chunk was then counted as loaded and consisted entirely of air, with no error and no log line.
DataVersion would answer the question outright, and the loader writes it at :1706 — it was never
read.

This adds a guard at the one seam in loadChunk where the full root compound exists and nothing has
been interpreted yet. It checks the layout first and the version second, because a version number
is a claim about the data while the layout is the data — so the guard does not rest on a version
table being correct.

The rule in three cases:

Input Result
Level layout refused, regardless of version
DataVersion missing accepted — foreign tools write worlds without it
DataVersion broken (wrong tag type, negative) refused

This converts nothing. It does not touch the save path and it does not make a pre-21w43a world
usable. Conversion is the separate falco-migration effort.

One number moved during implementation: the floor is 2844, not 2860. The layout change landed in
snapshot 21w43a (DataVersion 2844); 2860 is merely the first stable release carrying it. Verified
against minecraft.wiki/w/Data_version and /w/Java_Edition_21w43a, whose changelog reads "Removed
chunk's Level and moved everything it contained up. Level.Sections has moved to sections."

Design and plan are in the branch: docs/superpowers/specs/2026-08-03-anvil-version-guard-design.md
and docs/superpowers/plans/2026-08-03-anvil-version-guard.md.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • New feature (non-breaking change which adds functionality)
  • Performance change (behaviour unchanged, cost changed)
  • Documentation Update (if none of the other choices apply)

The break is behavioural, not binary: a caller feeding the loader a pre-21w43a world now gets an
exception where it got an air chunk. Every signature change is additive and
checkApiCompatibility passes — japicmp does not flag the new enum constant, which is last in the
declaration so a foreign exhaustive switch keeps compiling.

Checklist

  • I have read the CONTRIBUTING.md
  • The title of this pull request is a Conventional Commit
  • I have added tests that prove my fix is effective, written before the implementation and watched fail for the right reason
  • Tests are package-private, named test<What><Expectation>, and use plain JUnit assertions
  • Every new or changed class and method carries Javadoc with @param / @return / @throws
  • I have not written @NotNull
  • ./gradlew build is green locally
  • I have added necessary documentation — README plus the existing Anvil-Chunk-Loader wiki page

Counts taken from the JUnit XML, all six modules, --rerun-tasks: anvil 231, light 223, instance
259, demo 167, benchmarks 42 (1 skipped), archunit 47. No count fell.

Concurrency

The guard runs in the load path, which serves a virtual thread per chunk.

  • I have stated below which state the change adds or shares, and what guards it
  • Any new mutable state is either confined to one call or explicitly documented as thread-safe
  • I have added or extended a *ConcurrencyTest — see below

What is shared, and how is it guarded? The guard itself reads only its local data argument and
final fields (minimumDataVersion, regionDirectory, diagnostics). It adds no mutable state of its
own. The one shared structure it writes to is the new breakdown in AnvilDiagnostics, a
ConcurrentHashMap<String, LongAdder> that mirrors the existing partialChunkStatuses field
exactly — same cap over MAX_TRACKED_NAMES, same per-value throttle, same deliberate race between
the size check and the insertion, which is documented at the call site. The total counter increments
before every return path including the cap bail-out, so a version beyond the cap still counts toward
chunksSkippedAsUnsupported() and only loses its own map entry.

No new *ConcurrencyTest: the change shares no state the existing
AnvilDiagnosticsConcurrencyTest does not already cover for the identical structure it copies.

Measurements

No performance claim is made anywhere in this branch, and none may be quoted from it.

Further comments

Three things are deliberately not fixed here and belong on the open list:

  1. The air hole is only half closed. The third of the three causes named above —
    optionalList returning empty instead of throwing — is unchanged at :1506. A chunk with
    Status: minecraft:full and neither sections nor Level still reaches the caller as air. That
    is a pre-existing, different defect (a truncated chunk in the current format); this branch does
    not worsen it and does not claim to close it.
  2. Whether further decoder-relevant format changes lie between DataVersion 2845 and 2860 could not be
    established — the wiki marks itself incomplete there.
  3. A DataVersion stored with the wrong tag type appears in the diagnostics breakdown as -1, the
    sentinel of optionalInteger, indistinguishable from a genuine -1. Both are correctly refused,
    and the exception message does distinguish them; only the breakdown label is imprecise.

🤖 Generated with Claude Code

https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

TheMeinerLP and others added 12 commits August 3, 2026 22:35
A world written by 1.17 or earlier keeps its chunk data under `Level`. The loader looks for
`Status` and `sections` on the root, finds neither, and three defensible decisions combine into
silent loss: the status is absent so `isFullyGenerated(null)` says generated, `optionalList`
returns an empty list for the missing key, and the chunk is counted as loaded while consisting
entirely of air. `DataVersion` would answer the question outright and is written at :1586 without
ever being read.

The design checks the layout first and the version second, because a version number is a claim
about the data while the layout is the data. One new `Reason`, one builder slot, one diagnostics
pair, no change to the sealed hierarchy and none to `isFullyGenerated`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1 the reason and the diagnostics pair, task 2 the builder slot, task 3 the guard at the seam,
task 4 acceptance, task 5 documentation. Every task carries its Gegenprobe: which defect to inject,
which case must go red, which must stay green. The regression case matters as much as the two
failing ones - a world without a stored DataVersion must keep loading, and a Gegenprobe that reddens
it says the check rejects worlds it should read.

Two things verified against the baseline rather than assumed: builder() at :256 and build(Path, Key)
at :541, and that Builder exposes no readers, which decides how task 2 asserts. The 2860 floor comes
from the research and is flagged as the one number nobody read first-hand; the layout check holds
regardless, so a wrong constant misleads the message and not the behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-flight scan of the plan: the global constraint said only additive changes are permitted while
task 3 carries a breaking-change marker. Both are true and they bind different things - japicmp
checks signatures, the ! marks what the loader does with a pre-1.18 world. Stated so a reviewer does
not have to guess which one the line meant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documented the intentional design decision that threads can exceed the cap
by one entry each when racing on insertion. The version map is not trimmed
back because removing a counted version would lose its tally, and the counts
per version are the point of this map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FalcoAnvilLoader assumed the post-1.18 root layout with sections on the
root compound. A world from before 21w43a (DataVersion 2844) keeps
everything under Level instead, which decoded to an empty section list
and reached the caller as a fully-loaded chunk of air.

requireReadableVersion() runs at the seam between reading the raw NBT
and reading the chunk status. It rejects a chunk whose root has no
sections but does have a Level compound (the pre-1.18 shape) and a
chunk whose DataVersion is below the configured floor. The two checks
are asymmetric on purpose: a missing DataVersion alone is not a
rejection, because tools legitimately write worlds without one, and
those must keep loading; a legacy layout alone is.

The exception propagates through the existing failedLoad() path and is
therefore counted twice on purpose: once as an error, once in the
version breakdown.

This is a breaking change for a caller feeding the loader such a
world: it now gets an exception where it silently got an air chunk.
Review of the version guard found four gaps:

- The "<none>" diagnostics bucket for a legacy chunk with no
  DataVersion had no witness; a mutation reporting "-1" instead still
  passed all 28 tests.
- The Level half of legacyChunkLayout was only proven by an incidental
  test (partial-generation fixtures happen to lack sections); it now
  has a dedicated witness naming exactly what it protects.
- The sections-absent half of the same condition had no witness at
  all; added a fixture that carries both sections and Level and must
  not be refused.
- NbtReads.optionalInteger cannot tell "key absent" apart from "key
  present but not a number", so a DataVersion stored as the wrong tag
  type or as a negative number fell into the same "tool-written, keep
  loading" branch as a genuinely absent one. requireReadableVersion
  now checks presence directly (data.get(DATA_VERSION_KEY) == null)
  instead of inferring it from the parsed value, so only a truly
  absent key is lenient; a malformed or negative one is refused.

Each of the four fixes was proven with an injected mutation, observed
red, and reverted. Full report appended to
.superpowers/sdd/2026-08-03-anvil-version-guard/task-3-report.md.
Addresses the final review's code findings: the layout half of the
version guard checked only whether "sections" was present
(data.get(SECTIONS_KEY) == null), not what type it held, so a root
whose "sections" was a string next to a full Level compound passed
the guard and decoded to air. Switched to a type check
(!(... instanceof ListBinaryTag)) and added a test for that exact
shape, verified red against the old check.

The refusal's exception message also claimed a mistyped DataVersion
"stores data version -1" -- that's NbtReads.optionalInteger's
sentinel, not the stored value. The message now says the DataVersion
isn't stored as a number in that case, leaving the diagnostics
breakdown label and the negative-but-numeric case untouched.

Also pins down the deliberate double count of a refused chunk
(failedLoad's countError() plus the guard's own reporting) with an
assertion in testAChunkBelowTheFloorIsRefused, verified red when
countError() is pulled out of failedLoad.

Bumped @Version on FalcoAnvilLoader (1.2.0 -> 1.1.0, matching every
@SInCE 1.1.0 member this branch added) and on its Builder (1.0.0 ->
1.1.0, for the minimumDataVersion field and method it gained here).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR
The loader guarantees refusing worlds below snapshot 21w43a, not
reading 21w43a-and-newer worlds correctly -- there is no DataFixer,
and whether further decoder-relevant format changes sit between
DataVersion 2845 and 2860 is unresolved. Reworded to match the wiki
page, which already states this correctly.

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 4, 2026 08:46
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Test results

  285 files    285 suites   8m 57s ⏱️
  946 tests   945 ✅ 1 💤 0 ❌
2 865 runs  2 863 ✅ 2 💤 0 ❌

Results for commit a868454.

@TheMeinerLP
TheMeinerLP merged commit 1bdd0cc into main Aug 4, 2026
8 checks passed
TheMeinerLP added a commit that referenced this pull request Aug 4, 2026
…eplaceable services (#47)

* docs: carry the extension-point spec and plan onto their own branch

Stacked on feat/anvil-version-guard rather than main, because the plan moves the body of
requireReadableVersion and that only exists on #45. Rebase onto main once #45 lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(anvil): resolve a service once, and refuse to guess between two

* feat(anvil): make the version guard a service the caller can replace

Moves the decision logic of requireReadableVersion into ChunkVersionPolicy /
DefaultChunkVersionPolicy, unchanged, and resolves it through
ServiceResolution.choose the way Task 1 built it. The loader keeps counting
and logging a refusal itself; the policy only decides and throws. A builder
that never touches versionPolicy()/discoverVersionPolicy() keeps discovering
the default, so a plain constructor call refuses the same chunks it always
did. versionPolicy(null) is the only way to skip the check entirely.

* fix(anvil): let a foreign version policy win over the shipped default

Review of the version-guard service extraction found the discovery path
was unusable for any third party: falco-anvil registers its own
DefaultChunkVersionPolicy, so a caller who registered a policy the
documented way always hit "two providers" and got refused. Teaches
ServiceResolution.discover/choose an optional shippedDefault class that
steps aside for any single foreign provider; two foreign providers still
refuse each other, so "no silent choice between undocumented opinions"
still holds.

Also: ServiceLoader.load now pins the service's own classloader instead
of trusting the thread-context one, which is not reliable across a
CloudNet/extension/plugin boundary; documents the new IllegalStateException
on the three public entry points that can now throw it; adds the
pass-through tests the two new builder setters were missing; and corrects
a wrong test count in the task report.

* docs(spec): the shipped default has to step aside, or the seam is decoration

The resolution rules as written made discovery useless: falco-anvil registers its own provider, so a
third party taking the documented route would always produce two and always get the refusal.
Discovery could never return anything but the default. Found by the review of the first
implementation, fixed there, and now corrected here so the document does not keep teaching the
version that does not work.

Two rules gained along the way. Only foreign providers are counted for the ambiguity check, so the
default never appears in the error message. And discovery loads with the service's own class loader
rather than the thread context one - under CloudNet or extension class loaders the context loader may
not see the jar, and discovery would then silently find nothing and put the air chunk back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(anvil): let a caller decide what an unknown palette entry becomes

* fix(anvil): address review findings on the unknown-entry policy

- PaletteEntryResolver.toId's Javadoc now describes the contract this
  task's own change created: an implementation is allowed to fail,
  unchecked, instead of unconditionally promising a substitute.
- Add the biome mirror of every UnknownEntryPolicyTest case
  (BiomePaletteResolver had none before this).
- Narrow BiomePaletteResolver's three-argument constructor to
  package-private; it has no production caller and testability alone
  does not justify public API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

* docs(anvil): date the new members to the release they will actually land in

#45 merged with a breaking-change marker, so release-please raised the next version to 2.0.0 (PR
#46). Everything on this branch is additive on top of that, which makes it 2.1.0 - not the 1.2.0 the
tags carried from when this branch was written against a 1.x line. Nineteen tags across ten files.

@Version tags are untouched: those count a class's own revisions, not the artefact's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(anvil): record task 4 acceptance, including the archunit regression it found

Six modules re-run with --rerun-tasks, both gate attacks re-executed and reverted, javadoc/japicmp
re-verified. falco-anvil grew from 230 to 253 cases and every other module held; falco-archunit's
ForeignCouplingTest now fails 4 cases because its allow-list regex was never extended for the new
policy classes — reported here rather than fixed, per the acceptance's own instruction not to
silently repair a defect found during acceptance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

* fix(anvil): let the unknown-entry policy name a substitute instead of resolving one

DefaultUnknownEntryPolicy resolved Block.AIR and the biome registry itself, which archunit's
ForeignCouplingTest rightly refuses (registry access belongs to exactly one adapter per kind).
UnknownEntryPolicy now returns a palette name instead of an id; the resolver, which already holds
the registry from the original lookup, resolves the substitute itself and fails the chunk - without
asking the policy twice - if that name is unknown too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

* test(archunit): widen byteLayerKnowsNoNbt for the anvil policy classes

ChunkVersionPolicy, DefaultChunkVersionPolicy, UnknownEntryPolicy and
DefaultUnknownEntryPolicy carry NBT in their contract by design, one
layer above RegionFile's pure-byte guarantee that this rule actually
protects. Add all four to ANVIL_NBT_LAYER and correct the rule's
javadoc, which wrongly claimed the exemption was a self-maintaining
complement rather than the hand-maintained name list it always was -
the reason nobody extended it when these classes were added.

* docs(anvil): note that the version guard and unknown-entry fallback are replaceable

* docs(anvil): re-measure task 4 acceptance after the archunit fix closed the gap

Three commits landed since the last measurement (fe569f5, 4985431, a7f7b57): UnknownEntryPolicy now
names a substitute instead of resolving one, ANVIL_NBT_LAYER was widened and its Javadoc corrected, and
README gained the replaceable-policy paragraph. Re-ran all six modules and the build/javadoc/japicmp
check against the new tip: falco-archunit is back to 47/47, falco-anvil grew to 255 cases, no other
module moved. Replaces the previous Result section rather than appending a second one, and keeps the
archunit defect's discovery and actual fix in the history instead of quietly re-measuring it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

* fix(anvil): refuse a custom resolver configured together with an UnknownEntryPolicy

FalcoAnvilLoader.Builder.build(...) silently dropped the configured
UnknownEntryPolicy whenever a caller also supplied their own
blockResolver or biomeResolver: the resolver is used exactly as
given, so the policy was never reached even though
loader.unknownEntryPolicy() kept reporting it as active. A caller
who set both, e.g. unknownEntryPolicy(strict).blockResolver(mine),
got a loader that silently substituted air for an unknown block
instead of enforcing the configured policy.

The constructor now refuses that combination with an
IllegalStateException naming the slot to use instead, applying the
same standard ServiceResolution.choose already holds for explicit
configuration versus discovery. A resolver configured without
touching either unknownEntryPolicy slot is unaffected: a new
unknownEntryPolicyConfigured flag on the builder tracks whether
unknownEntryPolicy(...) or discoverUnknownEntryPolicy() was actually
called, separate from the default value both already carry.

Verified with a temporary mutation: disabling the new guard turned
the two new tests red, confirming they actually exercise it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

* docs(anvil): document that both policies are called concurrently and can be shared

ChunkVersionPolicy and UnknownEntryPolicy are resolved once when a
loader is built and then consulted by every parallel load
afterward, since FalcoAnvilLoader.supportsParallelLoading() is
true. Neither interface said so, leaving a foreign implementer to
discover the requirement by reading the loader rather than the
contract. Both javadocs now state the requirement explicitly and
note that the shipped defaults are stateless.

The two builder slots that accept an explicit instance
(versionPolicy(...), unknownEntryPolicy(...)) now carry the same
note the diagnostics slot already implies: an instance passed there
is shared by every loader built from that builder afterward, so it
has to tolerate exactly the concurrent, shared use the interface
javadoc now documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

* test(anvil): assert the legacy no-policy chunk actually decodes to air

testWithoutAnyPolicyALegacyChunkIsNotChecked only asserted
assertNotNull on the loaded chunk, even though its own javadoc
claims "it loads ... as a chunk of air" -- nothing verified that.
The test now reads a block back with the existing blockAt(...)
helper and asserts it is Block.AIR, so the assertion matches what
the test documents.

Verified with a temporary mutation: changing the expected block to
Block.STONE turned the test red, confirming the assertion is
actually exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

* test(anvil): assert the shipped default's name is absent from the refusal message

testTwoForeignProvidersAreStillRefusedEvenWithAShippedDefaultRegistered
only checked that the two foreign provider names appear in the
refusal message, never that the shipped default's name does not --
the other half of the rule the spec states: a default that stepped
aside for a foreign provider is not one of the competing candidates
and should not be named as one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

* docs(spec): pull the UnknownEntryPolicy signature in the design doc to what shipped

The design section still declared int onUnknownBlock(...) /
int onUnknownBiome(...) and said "returning an id substitutes it,"
but the interface that actually shipped returns String, not int.
The registry lookup that turns a name into an id belongs in the one
adapter that already owns it -- the resolver -- not duplicated into
every UnknownEntryPolicy implementation, which is why the signature
changed during implementation. The plan's Result section already
documents this as superseded; only the design section still showed
the old shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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