-
-
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.
Two 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. A measured claim names its benchmark, its parameters and its spread. Minestom is cited
by file and line at the pinned version 2026.06.20-26.1.2 [1], read from the published sources jar;
Falco is cited by member, because these sources move [5].
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 a fixed key set and nothing else survives (FalcoAnvilLoader#snapshot) [5].
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.
Conditions: 200 distinct block states, one fork with -Xms1g -Xmx1g, 3 warmup and 5 measurement
iterations of one second each, on a 16-core machine that was not idle. JMH takes one -t per
run, so the thread count was varied by running the benchmark four times. Microseconds per operation,
mean ± error as JMH reported it [4]:
| Threads | Falco read | Minestom read | Falco write | Minestom write | |
|---|---|---|---|---|---|
| 1 | 1 203 ± 123 | 1 060 ± 55 | 1.14× slower | 2 659 ± 212 | 2 601 ± 100 |
| 2 | 1 181 ± 31 | 2 200 ± 445 | 1.86× faster | 2 637 ± 204 | 2 643 ± 153 |
| 4 | 1 378 ± 84 | 11 021 ± 16 470 | 8.00× faster | 2 672 ± 43 | 2 678 ± 115 |
| 8 | 2 438 ± 252 | 530 905 ± 1 928 261 | not usable | 3 098 ± 658 | 3 102 ± 245 |
Four things follow, two of which do not flatter this loader.
Single-threaded, Falco is the slower loader. By 14 % at 200 distinct states, and by 20 % at 8 states, where the payload is smaller and the fixed cost of the pipeline is a larger share of it. This is not noise hiding a win: the direction is consistent across both parameter values. 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. This row is what that claim costs when there is no lock to contend for.
Writing is a tie at every thread count, between 1.00× and 1.05× in either direction. Both
implementations take a lock for the sector allocation and the header update, so there is nothing
structural to gain on this path, and the numbers agree. 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 from two threads on, and the gap then widens with every thread added: 1.86× at two, 8.00× at four, and beyond arithmetic at eight. Falco's own cost grows by roughly a factor of two from one thread to eight, which is what several threads sharing one page cache and one disk look like; Minestom's mean grows by a factor of five hundred over the same range.
The eight-thread row must not be quoted as a factor. Its error, ±1 928 261 µs/op, is nearly four times its mean of 530 905. That is not a measurement of a cost; it is a measurement of the absence of one. What the row legitimately shows is a loss of predictability — Minestom's read time scatters over three orders of magnitude while Falco stays inside ±10 % — and for a server that is the worse of the two failures, because an unpredictable load latency is what a tick budget cannot absorb.
The same caution applies, more weakly, one row up, and this document states it where STATUS.md
does not. At four threads Minestom's error of ±16 470 µs/op already exceeds its mean of 11 021, so
8.00× is a direction, not a stable factor either. The only row in which Minestom's spread is
smaller than its own mean is the two-thread row, 2 200 ± 445 against Falco's 1 181 ± 31. That row —
1.86× — is the one number from this table that can be quoted as a factor without a footnote. Everything
above it says that the built-in region file stops having a predictable cost, which is a stronger claim
than any multiplier and a worse property to run a server on.
One further honesty note, recorded in anvil-chunk-loader.md 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. The direction reproduces across independent runs; the mechanism behind the magnitude —
lock convoying, scheduler interaction, page-cache behaviour under a held lock — has not been
investigated. Do not quote the factor as if it were understood [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)
ChunkSaveStageBenchmark exists to turn that design claim into a number rather than an intention. On
a 24-section chunk with 200 distinct block states [4]:
| Stage | Time | Lock held |
|---|---|---|
| Snapshot | 64 µs | chunk read lock |
| Codec, without compression | 1 356 µs | none |
| zlib compression | 2 701 µs | none |
| Transfer | 17 µs | region lock |
About 97 % of a save runs outside any lock, and compression alone is 63 % of the whole operation. That second figure is the one that decided the optimisation target: it is why the loader defaults to zlib level 2 rather than the platform default 6 — 1.83× faster compression for roughly 3 % more stored bytes [4] — and why no effort went into the palette, which is a small fraction of the same path.
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 [4].
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 sized to the available processors (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 not cosmetic — 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 (recorded in [4] against JEP 444; not re-measured here).
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);
chunk.onGenerate();
}
cacheChunk(chunk);(instance/InstanceContainer.java:335-347). 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'sIOException-only catch (AnvilLoader.java:402), 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 STATUS.md 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 is worth less than nothing when chunks are loaded one at a time — it is measurably slower there — 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. -
STATUS.md— this repository. The working record: benchmark results with their conditions, the decision table, the defects found and fixed, and the claims that were investigated and refuted. -
docs/anvil-chunk-loader.md— this repository. 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. -
docs/benchmarks.md— this repository. Benchmark methodology, what each benchmark measures, and the standing caveats about what the numbers are not. -
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.