-
-
Notifications
You must be signed in to change notification settings - Fork 0
Rationale Chunk Loading
Minestom already ships a working Anvil chunk loader. Writing a second one is expensive, it is a maintenance liability against a moving upstream, and it starts from behind on everything the first one already does. This document answers why it was done anyway, why the result is shaped as a three-stage pipeline rather than a faster version of the same structure, and where the decision does not pay off. It is written for someone deciding whether to trust this code, and for whoever on this side of the fence proposes to reopen one of these questions in a year.
Three kinds of statement appear below and are marked wherever it matters. A structural claim is
something read in the source of one implementation or the other; it says what the code does, not what
it costs, and it is the strongest kind here because it cannot be dismissed as measurement noise. A
measured claim names its benchmark, its parameters, its run configuration and its spread. A
judgement is an argument from the properties of the design that nobody measured, and it says so.
Minestom is cited by file and line at version 2026.06.20-26.1.2 [1], read from the published
sources jar; Falco is cited by member, because these sources move [5]. That Minestom version is what
this repository compiles against: settings.gradle.kts declares minestom withoutVersion() and
takes it from net.onelitefeather:mycelium-bom:1.7.2, whose dependencyManagement pins exactly
2026.06.20-26.1.2.
Every measured figure below belongs to a table in Benchmarking or
Project Status; what the ± after a mean does and does not cover is stated once,
in Rationale: Measurement, and is not restated here.
Starting with the case for not writing this, because it is stronger than the comparison table in Anvil Chunk Loader makes it look.
net.minestom.server.instance.anvil.AnvilLoader reads and writes the Anvil region format, resolves
block and biome palettes against the server registries, restores heightmaps from NBT
(instance/anvil/AnvilLoader.java:140), handles level.dat through loadInstance and
saveInstance (:96, :332), and — the part Falco still does not match — keeps every chunk-level
tag it does not itself understand in the chunk's tag handler and writes it back on save (:144-151).
A vanilla world saved through AnvilLoader keeps its structures, its block_ticks and its
fluid_ticks. The same world saved through FalcoAnvilLoader loses all of them, because snapshot
builds one fixed key set — DataVersion, xPos, zPos, yPos, Status, LastUpdate, sections,
block_entities — and nothing outside it survives (FalcoAnvilLoader#snapshot) [5]. Heightmaps is
not in that set either, so a saved chunk carries none.
So the honest starting position is that the built-in loader is more complete as a world tool, and that this loader is the narrower instrument. What it is narrower for is the subject of the rest of this document.
AnvilLoader reports both parallel capabilities as available:
public boolean supportsParallelLoading() { return true; } // AnvilLoader.java:587-589
public boolean supportsParallelSaving() { return true; } // AnvilLoader.java:592-594InstanceContainer.retrieveChunk reacts to the first of these by starting a virtual thread per chunk
(instance/InstanceContainer.java:362-372). The threads are real. What they are allowed to do
concurrently is the question.
Every load goes through RegionFile.readChunkData, and that method holds one ReentrantLock —
declared at instance/anvil/RegionFile.java:42, one instance per open region file — across its
entire body (:67-92):
lock.lock();
try {
...
file.seek((long) (location >> 8) * SECTOR_SIZE); // :73
int length = file.readInt(); // :74
int compressionType = file.readByte(); // :75
byte[] data = new byte[length - 1];
file.read(data); // :85
return TAG_READER.read(new ByteArrayInputStream(data), compression); // :88
} finally { lock.unlock(); }The last line is the one that matters. BinaryTagIO.Reader.read inflates the payload and parses
it into a CompoundBinaryTag; both happen at :88, inside the lock. So the seek, the byte transfer,
the zlib inflate and the NBT parse are one critical section per region file. This is a statement
about the source, not a measurement.
The size of the resource being guarded is what turns that into a practical problem. A region file
addresses 32×32 chunks [2], so a server loading a spawn area, a lobby or any contiguous piece of
world is dispatching many virtual threads at one lock. They queue. Adding threads adds queue, not
throughput. supportsParallelLoading() == true is therefore true of the dispatch and not of the
work.
The structure has a plain historical explanation rather than a careless one: Minestom's RegionFile
names Hephaistos as its reference implementation in its own class javadoc (RegionFile.java:23) [7],
and it uses RandomAccessFile, whose seek/read pair is stateful and genuinely cannot be shared
between threads without exclusion. Once the file handle is a cursor, a lock around the read is
forced. Everything else ended up inside it because it was already there.
The same pattern appears twice more, and neither is about the region lock:
-
Saving holds the chunk's write lock across the whole serialisation of every section —
palettes, block entities, biome lookups, packing (
AnvilLoader.java:420-519). A write lock excludes readers, so for the duration of encoding a chunk nothing may look at it. -
saveChunksis not overridden, so the interface default applies:phaser.register()and one unbounded virtual thread per chunk (instance/ChunkLoader.java:62-82). Itscatchbranch (:71-73) reports the exception and returns without callingphaser.arriveAndDeregister(), so a singleThrowableescapingsaveChunkleaves a registered party that never arrives andphaser.arriveAndAwaitAdvance()at:76blocks for good. That is a liveness defect, read from the source and not provoked here.
RegionFileComparisonBenchmark measures the two region file implementations against each other on
the same stored bytes, through the same Adventure writer, at the same compression level, from a
stored chunk to a parsed CompoundBinaryTag [6]. It lives in net.minestom.server.instance.anvil
because Minestom's RegionFile is package-private. Each benchmark thread is handed a different
chunk of the same region file, so no two threads contend for one location entry; the only shared
resource is the file and whatever guards it — which is exactly the variable under test.
Microseconds per operation, mean ± half-width as JMH reported it [4]. The rightmost column of the read pair states what the two intervals allow to be concluded, which is not the same thing as the quotient of the two means:
| Threads | Falco read | Minestom read | Falco write | Minestom write | |
|---|---|---|---|---|---|
| 1 | 1 203 ± 123 | 1 060 ± 55 | intervals overlap | 2 659 ± 212 | 2 601 ± 100 |
| 2 | 1 181 ± 31 | 2 200 ± 445 | 1.9× faster | 2 637 ± 204 | 2 643 ± 153 |
| 4 | 1 378 ± 84 | 11 021 ± 16 470 | no factor: ± exceeds the mean | 2 672 ± 43 | 2 678 ± 115 |
| 8 | 2 438 ± 252 | 530 905 ± 1 928 261 | no factor: ± is 3.6× the mean | 3 098 ± 658 | 3 102 ± 245 |
RegionFileComparisonBenchmark.falcoRead / .minestomRead / .falcoWrite / .minestomWrite,
distinctStates = 200, one thread count per row passed as -t (JMH takes one -t per run, so this
is four separate runs), one fork with -Xms1g -Xmx1g, annotated 3 warmup and 5 measurement
iterations of 1 s — the iteration time actually used is not recorded — JMH 1.37, one 16-core machine
recorded as not idle, no results.json committed. One fork: the ± covers variance between
iterations of one JVM, not between JVM launches — see
Rationale: Measurement. This is the first of two runs of this benchmark;
the second is tabulated in
Anvil Chunk Loader under the
same stated configuration, and its Minestom means differ from these by up to a factor of 47, in both
directions. No row of one may be held against a row of the other.
Four things follow, two of which do not flatter this loader.
Single-threaded, Falco's mean is the higher of the two, and the two intervals overlap. Falco
spans [1 080, 1 326] against Minestom's [1 005, 1 115], so a 14 % difference is not resolved at
this precision and must not be stated as one. What can be said is the direction of the means, that it
is the same direction at 8 distinct states as at 200, and the mechanism that predicts it: the
pipeline has a setup cost — the version counter on every entry, the strict NBT accessors, the length
validation against the sector allocation — and with no contention there is nothing to win it back
from. The claim was always that the gain is in lock granularity, and this is the row where there is
no lock to contend for. Nothing here licenses the opposite reading either: an unresolved difference
is not evidence that the two are equal.
Writing shows no difference at any thread count. All four pairs of intervals overlap, so no
ordering is resolvable in either direction. That is the predicted outcome rather than a null
result: both implementations take a lock for the sector allocation and the header update, so there is
nothing structural to gain on this path. Falco's header write is 8 bytes against Minestom's full
8 192-byte rewrite (RegionFile#writeEntry against RegionFile.java:182-201), and that difference
does not show up here at all — it is a crash-window argument, not a throughput one.
Reading inverts at two threads, and that row is the solid one. The intervals are disjoint —
[1 150, 1 212] against [1 755, 2 645] — so the difference is real for this run. The conservative
bounds on the ratio are 1.45× to 2.30×; the point estimate is quoted as 1.9× and not to three
figures, because Minestom's half-width is 20 % of its mean and a ratio cannot be more precise than
its operands. This is the one row of the table from which a factor may be quoted at all.
The four- and eight-thread rows must not be quoted as factors, and the same test disqualifies
both. At eight threads the half-width, ±1 928 261 µs/op, is 3.6 times the mean of 530 905. At four
threads ±16 470 already exceeds the mean of 11 021 — 149 % of it — which puts the lower end of that
interval below zero. A measurement whose interval contains zero does not constrain the value, so
neither row supports a multiplier at any precision, and the 8.00× these rows were previously
summarised with has been withdrawn. What both rows legitimately show is the same thing, and it is
the more interesting finding: Minestom's read time stops being predictable under contention. It
scatters over three orders of magnitude while Falco stays inside ±10 % of its own mean at every
thread count. For a server that is the worse of the two failures, because an unpredictable load
latency is what a tick budget cannot absorb. Background load on the machine, which was not idle, is
a candidate contributor to the width of those intervals and has not been ruled out.
One further honesty note, recorded in Anvil Chunk Loader and worth repeating here: the magnitude of the collapse is not explained. Pure serialisation of a ~1 060 µs operation across four threads predicts roughly 4 200 µs, not 11 000, and certainly not the six-figure numbers at eight threads. Both operands of that estimate come from the table above, so it is derived inside one session; the contended figures of the second run and of the two-fork control run are larger again, and the direction is the only thing all three sessions agree on. The mechanism behind the magnitude — lock convoying, scheduler interaction, page-cache behaviour under a held lock — has not been investigated. This is the reason the numbers above are read as a loss of predictability rather than as a cost: nobody here can say what the cost is [5].
The measurement above says where the loss is. It does not say what to do about it, and the obvious
answer — make the lock finer, or swap the ReentrantLock for a ReadWriteLock — was rejected before
any of this was built.
The design instead splits both directions into three stages, on one rule: no CPU-bound work happens while a lock is held.
loadChunk: read raw bytes -> inflate + parse + decode -> apply to chunk
(no region lock) (no lock at all) (chunk write lock)
saveChunk: clone sections -> encode + write NBT + deflate -> write sectors
(chunk read lock) (no lock at all) (region lock)
The lock structure itself is a structural claim and the strongest thing in this section: it is
verifiable by reading FalcoAnvilLoader#saveChunk and needs no benchmark at all. What the benchmark
adds is how much the lock-free part weighs. ChunkSaveStageBenchmark measures each stage on a
24-section chunk with 200 distinct block states [4]:
| Stage | Time | Lock held | Kind |
|---|---|---|---|
| Snapshot | 64 µs | chunk read lock | measured (snapshot) |
| NBT tree, without serialisation or compression | 1 356 µs | none | measured (codecWithoutCompression) |
| NBT serialisation and zlib compression | 2 701 µs | none |
derived: codec − codecWithoutCompression
|
| Transfer | 17 µs | region lock | measured (transfer) |
ChunkSaveStageBenchmark.snapshot / .codec / .codecWithoutCompression / .transfer,
distinctStates = 200, one thread, one fork with -Xms1g -Xmx1g, 5 warmup and 5 measurement
iterations of 1 s per the class annotation, JMH 1.37, one 16-core machine recorded as not idle, no
results.json committed. No ± was recorded for any row, so no significance rule can be applied
to this table and no ratio taken from it is defensible; read the four values as magnitudes. One fork:
the ± that is missing would in any case have covered only variance inside one JVM — see
Rationale: Measurement.
Three things about that table have to be said before it is used.
The third row is derived, not measured, and its label used to be wrong. codec() is
compress(serialize(encode(...))) while codecWithoutCompression() is encode(...) alone — it
returns a CompoundBinaryTag and never serialises it. Their difference therefore contains the NBT
serialisation and the deflate, not the deflate alone, and it is labelled that way above. The value
2 701 is correct for what it actually measures; what changed is what it is called.
The benchmark also declares a full() method that performs the whole save, which exists precisely so
the sum of the stages can be checked against the whole. Its result is not published, so nobody
reading this can verify the decomposition. That is a gap in the evidence, not in the design.
The share that runs outside a lock is therefore about 98 % — 64 + 17 = 81 µs locked against 1 356 + 2 701 = 4 057 µs unlocked, on a 4 138 µs total — and NBT serialisation together with zlib is about 65 % of it. The figures previously stated here, 97 % and 63 %, are each a little more conservative than the rows above produce and their derivation is not recorded.
That second share is the one that decided the optimisation target — it is why the loader defaults to
zlib level 2 (ChunkCompression.DEFAULT_LEVEL = 2) rather than the platform default 6, and why no
effort went into the palette, which is a small fraction of the same path. The figure usually attached
to that choice, 1.83× faster compression for roughly 3 % more stored bytes, has no run record:
the harness carries ChunkSaveComparisonBenchmark.compressFalcoLevel and .compressMinestomLevel
for exactly this comparison, and neither of those two methods has ever been published, so no
conditions and no uncertainty were kept for the figure. The same choice is quoted elsewhere at
2.4× at 256 distinct states and 3.1× at 1 024, with the same 3 % rider
(Anvil Chunk Loader). All three come from the same
unrecorded design-time measurement, none of them is a result of the JMH suite, and they cannot be
reconciled from anything committed. The direction is what carries: level 2 compresses faster than
level 6 and stores a little more.
Three consequences of the split are worth stating explicitly, because each was a decision.
Load stage 1 takes no lock at all. RegionFile#readRaw uses FileChannel.read(ByteBuffer, long),
which does not mutate the channel position, so two readers of different chunks do not interfere. This
is the structural difference from RandomAccessFile that makes the whole thing possible. It also
introduces a hazard that the locked version does not have: a chunk being rewritten frees its sector
range, and the allocator may hand that range straight back out while a reader is still inside it.
That is not hypothetical — it was found as a live defect, with readers observing filler markers where
their own payload belonged [4]. The answer is a per-entry seqlock: each of the 1024 entries carries a
version counter that a writer raises on entry to and exit from its critical section; the reader takes
the counter, rejects an odd one, reads, and takes it again, and after OPTIMISTIC_ATTEMPTS = 4
failures falls back to the writer lock so a chunk rewritten in a loop cannot starve it
(RegionFile#readRaw).
A ReadWriteLock would have been the smaller change and was rejected for a specific reason: it
blocks every reader of a region for the length of a payload write, which is precisely the property
this loader exists to avoid. The alternative of deferring sector frees was rejected too — it brings
the contention back through a shared counter and grows the files, and the format has no compaction
[4]. Both rejections are arguments from the mechanism, not measured comparisons: neither variant
was built, so nobody here can say what either would have cost. The variants and the reasoning are set
out in full in Rationale: Concurrency.
Save stage 1 takes the chunk's read lock, not its write lock. Minestom holds the write lock across
the entire serialisation (AnvilLoader.java:420-519); Falco holds the read lock only long enough to
clone every Section and collect the block entities (FalcoAnvilLoader#snapshot), and everything
after that works on the clones. The chunk stays readable while it is being serialised. The price is
an allocation Minestom does not make — the palette representation is a value record, so a snapshot
allocates where Minestom mutates in place [5]. That trade is the whole point, and it is a trade.
saveChunks is overridden. Chunks are grouped by region index, one task per region, bounded by a
Semaphore of Math.max(Runtime.getRuntime().availableProcessors(), 2) permits
(FalcoAnvilLoader#saveChunks, FalcoAnvilLoader.saveLimit), with every result collected in
FalcoAnvilLoader#awaitAll. This fixes two independent problems in the inherited default: the
Phaser liveness defect described above, and the fact that one thread per chunk means every chunk of
a region contends for that region's lock while all of their snapshots are alive at once. The bound is
a judgement, not a measured optimum — file I/O does not unmount a virtual thread from its
carrier, so unbounded virtual threads over file work do not scale and have to be bounded explicitly
(the reasoning is recorded in [4] against JEP 444; no bound other than this one was benchmarked).
They do not make NBT parsing faster: both implementations use adventure-nbt 5.1.1. They do not make
compression faster: both use java.util.zip, and deflate dominates the save path in either. They do
not help a single-threaded workload at all, as the one-thread row above says outright. The design is
one bet — that the expensive work should not be serialised — and the benchmark is the test of that
bet, not of the loader in general.
This is the decision with the largest consequence per line of code, and it is not about performance.
ChunkLoader.loadChunk is declared @Nullable Chunk with the javadoc "the chunk, or null if not
present" (instance/ChunkLoader.java:41-43). null means absent, and InstanceContainer acts on
that meaning: in retrieveChunk, the consumer that receives the loader's result begins
if (chunk == null) {
// Loader couldn't load the chunk, generate it
chunk = createChunk(chunkX, chunkZ); // InstanceContainer.java:340
chunk.onGenerate(); // :341
}
cacheChunk(chunk); // :346(instance/InstanceContainer.java:335-347; the three EventsJFR.newChunkGeneration calls
interleaved with those lines are elided, as they change nothing about the control flow). The
generated replacement is a normal, dirty, cached chunk. The next saveChunk writes it over the
stored bytes.
AnvilLoader.loadChunk collapses every failure into that path:
try {
return loadMCA(instance, chunkX, chunkZ);
} catch (Exception e) {
MinecraftServer.getExceptionManager().handleException(e);
return null; // AnvilLoader.java:115-120
}The catch is Exception, so a transient IO error, a temporarily unavailable mount, a truncated
region file, an unsupported compression scheme and a parser bug all produce the same result: the
chunk is reported absent, regenerated, and destroyed on the next save. The operator sees one handled
exception and a world that looks intact.
Two of the entries in that set are not hypothetical, and both are visible in the same file:
-
Objects.requireNonNull(Block.fromKey(blockName), "Unknown block " + blockName)(AnvilLoader.java:263). One modded or newer-version block name anywhere in the chunk throws an NPE out ofloadSections, which thecatch (Exception)above swallows. The chunk is regenerated. -
file.read(data)atRegionFile.java:85discards its return value.RandomAccessFile.readmay return fewer bytes than requested; the tail ofdatathen stays zero-filled and is handed to the NBT parser, which fails, which lands in the samecatch.
The governing rule Falco adopted instead: a chunk that exists on disk but cannot be read must not
be reported as absent. FalcoAnvilLoader#loadChunk returns null for exactly three cases, all of
which genuinely mean absent — no region file (FalcoAnvilLoader#acquireRegion without create), no
location entry for the chunk (RegionFile#readEntry), and a chunk present but not fully generated
(FalcoAnvilLoader#isFullyGenerated, which matches the intent of the Status field [3]). Every
other failure is logged with the chunk, region and dimension, reported to the exception manager and
rethrown as AnvilChunkException (FalcoAnvilLoader#failedLoad). InstanceContainer then completes
the load future exceptionally (InstanceContainer.java:367-371) instead of generating, the chunk
stays unloaded, and the bytes on disk are untouched. AnvilChunkException is unchecked because the
ChunkLoader interface declares no checked exceptions.
Note the asymmetry, because it is deliberate rather than an oversight: saveChunk does not
throw. It logs at error level, counts the failure and reports it (FalcoAnvilLoader#saveChunk). A
failed save has already lost the in-memory state either way, and propagating would additionally
abort the save of every other chunk in the batch. The one exception is a call on a closed loader,
which is a lifecycle error of the caller rather than a broken chunk and propagates as
IllegalStateException. Returning null there would report a chunk as absent during shutdown, which
is the same data-destroying contract in a different costume; waiting was not an option either,
because Minestom owns the load tasks and there is nothing to wait on [4].
The same reasoning drives the strict NBT layer. Every CompoundBinaryTag getter returns a default
for a missing or mistyped key, so sectionData.getCompound("block_states") on a truncated section
yields an empty compound and the section loads as air (AnvilLoader.java:239, :242-249). NbtReads
exists so that a missing or mistyped key is an IOException naming the key, the expected type and
the actual type — which converts a silent rewrite of real data into a failed load [5].
None of the above is a free design space. Anvil is defined by the game, and every structural decision
in RegionFile is a consequence of the on-disk layout rather than a preference.
A region file begins with two 4 KiB sectors: a location table and a timestamp table, each with one
4-byte entry for all 32×32 chunks [2]. A location entry packs a three-byte sector offset with a
one-byte sector count, which is why RegionConstants.MAX_SECTORS_PER_CHUNK is 255 and why a chunk
payload cannot exceed roughly 1 MiB in place. Everything about the loader's concurrency follows from
this shape:
- A chunk's storage is a range of sectors that can move. Rewriting a chunk frees its old range, and the allocator may reuse it immediately. Lock-free reads are only correct because the location entry carries a version counter — the seqlock described above exists because the format allows a chunk to relocate, not because a lock was disliked.
-
The location table is the index, and it is the thing that must not tear. Falco updates the
4-byte location and 4-byte timestamp of one entry (
RegionFile#writeEntry); Minestom rewrites all 8 192 bytes on every dirty save (RegionFile.java:182-201, called at:131). Beyond the write volume, a crash during the full rewrite can damage entries of chunks that were not being saved. An 8-byte update cannot. -
The one-byte sector count is a hard ceiling, and the format's own answer to it is an external
file. When the compression scheme byte has 128 added to it, the payload lives in
c.<x>.<z>.mccnext to the region file instead of in the sectors [2]. Minestom does not implement this.Check.stateCondition(sectorCount >= SECTOR_1MB, ...)atRegionFile.java:102throwsIllegalStateExceptionwith the message Chunk data is too large to fit in a region file, and it propagates out ofsaveChunk'scatch (IOException)atAnvilLoader.java:401, so an oversized chunk simply cannot be saved at all. Falco writes the external file, setsChunkCompression.EXTERNAL_FLAG = 0x80, and deletes a stale.mccwhen a chunk shrinks again.
The external file is also where the format's constraint and the operating system's collide, and it
is worth recording because the mechanism is not obvious. The .mcc is the one place where a
lock-free read meets a name in the file system rather than a range inside the region file, and a
name is not a POSIX concept. Under POSIX a deletion detaches the name at once and keeps the unnamed
file alive for every open handle, so nothing is noticed. Windows leaves the name in the directory and
marks the file delete-pending: while any reader holds it open, every later open of that name and
every move onto it is denied. The result was a loader that broke on Windows as soon as an oversized
chunk was read while being saved — present in the last green commit, invisible on Linux, and only
reproducible on the CI runner. The fix renames onto a private name with ATOMIC_MOVE before
deleting, and the header entry plus the rename now happen together inside the region lock, with only
the bytes written outside it into a staging file [4]. This is reasoning from platform semantics, not
a measurement.
Two more format details were settled the same way and are recorded here so they are not re-argued:
-
The chunk length field is
1 + N, not5 + N. The format defines it as the compression byte plus the payload. Minestom writesCHUNK_HEADER_LENGTH = 4 + 1plus the payload (RegionFile.java:31,:99, written at:123) and compensates in its own reader by readinglength - 1bytes (:84). Its files are therefore self-consistent, but every chunk it writes declares four bytes more than it holds, and a spec-conforming reader over-reads into sector padding — or past the allocation, when the payload ends within four bytes of a sector boundary. -
LZ4 (type 4) and custom compression (type 127) are not supported, on purpose.
ChunkCompression#fromIdaccepts gzip, zlib and none, and rejects anything else with an explicit message rather than misreading it as another scheme. A world written withregion-file-compression=lz4cannot be read by this loader. That is a real limitation, taken deliberately to avoid an extra dependency [5].
The requirement is easy to state and easy to overstate, so both halves belong in the same section.
Why it matters. A server that loads a world containing block or biome names its registry does not
know — a world from a newer game version, a world touched by a modded tool, a world with a datapack
biome that is no longer installed — must not respond by destroying the parts it does understand.
Minestom's response is the requireNonNull at AnvilLoader.java:263: an unknown block name raises
an NPE out of loadSections, the catch (Exception) at :117-120 turns it into null, and
InstanceContainer regenerates the whole chunk over data that was intact. One name the registry has
never seen costs the entire chunk, and then costs it permanently on the next save. Biomes take the
opposite and equally unhelpful route: an unknown biome silently becomes PLAINS_ID with no report at
all (AnvilLoader.java:294-296), so a world referencing missing biomes is quietly rewritten to
plains.
What Falco actually does, stated plainly. BlockPaletteResolver#toId substitutes
Block.AIR.stateId() and reports the name once through the diagnostics;
BiomePaletteResolver#toId substitutes plains and reports the name once. So the chunk survives, and
the surrounding world survives — but the unknown block does not survive the round trip. It is air
on load and it is air on the next save. The same is true of the biome, which becomes plains. This is
listed in Project Status under known deviations from vanilla with exactly that
framing: "better than discarding the chunk, but not what a data fixer does" [4].
The difference from Minestom is therefore one block state against one chunk, plus a log line and a counter against silence. That is a real improvement and it is not the same thing as preservation. A loader that genuinely preserved unknown entries would have to keep the original palette entry alongside the resolved state id and write the original back on save, which nothing in this codebase does today. Two further honest limits sit next to it:
-
There is no DataFixer and no
DataVersionmigration.MinecraftServer.DATA_VERSIONis written into every saved chunk, but the storedDataVersionof a chunk being read is never inspected. Data from an older world version is interpreted with the current schema [5]. -
Diagnostics saturate.
AnvilDiagnosticstracks at mostMAX_TRACKED_NAMES = 64distinct names per category, so once 64 distinct unknown block names have been seen, further distinct names are substituted silently andunknownBlockCount()stops rising [5]. The cap exists because a heavily modded or corrupt world can contain an unbounded number of distinct names; the consequence is that the counter is a floor, not a total.
The conclusion a reader should draw is narrow. Use this loader for worlds the server owns. It will
not destroy a chunk because of a name it does not recognise, and it will tell you the name. It is not
a migration tool and it is not an editor for vanilla worlds you intend to open in the client again —
for that, the built-in loader's preservation of unknown chunk tags and level.dat is the better
behaviour and is documented as such above.
The loader is worth having when several threads read chunks of the same region concurrently, when a read failure must stay visible rather than be silently replaced by a generated chunk, and when a world may contain blocks or biomes the server does not know. It buys nothing when chunks are loaded one at a time — the single-thread row resolves no advantage in either direction and Falco's mean is the higher one — and it is the wrong tool for a world that has to remain interchangeable with vanilla, because it drops every chunk-level tag it does not itself write.
It is also opt-in by construction. Nothing switches to it: a server keeps whatever loader it uses
until it constructs a FalcoAnvilLoader explicitly, or passes a ChunkLoaderFactory to the map
provider of Aves [8], which is a functional interface neither library depends on the other for. Every
public type of the package is @ApiStatus.Experimental [5].
-
Minestom — source repository, https://github.com/Minestom/Minestom. All Minestom file and line
references in this document are to version
2026.06.20-26.1.2, read from the published sources jarnet.minestom:minestom:2026.06.20-26.1.2. Files cited:net/minestom/server/instance/anvil/AnvilLoader.java,net/minestom/server/instance/anvil/RegionFile.java,net/minestom/server/instance/ChunkLoader.java,net/minestom/server/instance/InstanceContainer.java. -
Region file format — Minecraft Wiki, https://minecraft.wiki/w/Region_file_format. Source for
the sector size, the two header sectors, the location-entry layout, the one-byte sector count and
the external
.mccmechanism at compression scheme + 128. -
Chunk format — Minecraft Wiki, https://minecraft.wiki/w/Chunk_format. Source for the
chunk-level tag set (
DataVersion,xPos,zPos,yPos,Status,sections,block_entities,Heightmaps,structures,block_ticks,fluid_ticks) and for thepalette/datalayout of a section. - Project Status — the working record: benchmark results with their conditions, the decision table, the defects found and fixed, and the claims that were investigated and refuted.
- Anvil Chunk Loader — the loader in detail: the twenty-row comparison with the built-in loader, the error-handling contract, the logging schema and the explicit list of what the loader does not do. Falco sources referenced by member name live under https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/.
-
Benchmarking — benchmark methodology, what each benchmark measures, and the
standing caveats about what the numbers are not.
Rationale: Measurement carries the meaning of
±and the per-class run configurations. Benchmark sources: https://github.com/OneLiteFeatherNET/Falco/tree/main/falco-benchmarks/src/jmh/java. -
Hephaistos —
RegionFile.kt, named by Minestom's ownRegionFileclass javadoc (RegionFile.java:23) as its reference implementation, at https://github.com/Minestom/Hephaistos/blob/master/common/src/main/kotlin/org/jglrxavpok/hephaistos/mca/RegionFile.kt. Cited here as the attribution Minestom itself gives; the linked file was not independently inspected for this document. -
Aves — OneLiteFeather's utility library, https://github.com/OneLiteFeatherNET/Aves. The map
provider that can be handed a
ChunkLoaderFactory.
Most of the measured tables are owned by Benchmarking and
Project Status; a page that carries one of its own says so where the table stands.
What the ± after a JMH mean covers is stated once, in Rationale: Measurement.
Wiki home · Repository · README and quick start · API documentation · Issues · Licence: AGPL-3.0
Start here
- Quick start (README)
- Installation
- Anvil Chunk Loader
- Light Engine
-
Rationale: Instances and Chunks — the third module,
falco-instance
The measured record
Why it is built this way
- Rationale — the index for the five rationale pages
- Research — the index for the five investigations
- Research: Fluent API — a proposal; describes no existing API
Working on the build