Skip to content

Rationale Chunk Loading

TheMeinerLP edited this page Aug 1, 2026 · 2 revisions

Why Falco has its own Anvil chunk loader

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.


What the built-in loader actually does

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.

Where the parallelism is nominal rather than real

AnvilLoader reports both parallel capabilities as available:

public boolean supportsParallelLoading() { return true; }   // AnvilLoader.java:587-589
public boolean supportsParallelSaving()  { return true; }   // AnvilLoader.java:592-594

InstanceContainer.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.
  • saveChunks is not overridden, so the interface default applies: phaser.register() and one unbounded virtual thread per chunk (instance/ChunkLoader.java:62-82). Its catch branch (:71-73) reports the exception and returns without calling phaser.arriveAndDeregister(), so a single Throwable escaping saveChunk leaves a registered party that never arrives and phaser.arriveAndAwaitAdvance() at :76 blocks for good. That is a liveness defect, read from the source and not provoked here.

What the measurement says, and what it does not

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].

Why three stages

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: codeccodecWithoutCompression
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).

What the three stages do not buy

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.

Why a read failure throws instead of returning null

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 of loadSections, which the catch (Exception) above swallows. The chunk is regenerated.
  • file.read(data) at RegionFile.java:85 discards its return value. RandomAccessFile.read may return fewer bytes than requested; the tail of data then stays zero-filled and is handed to the NBT parser, which fails, which lands in the same catch.

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].

The format is the constraint

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>.mcc next to the region file instead of in the sectors [2]. Minestom does not implement this. Check.stateCondition(sectorCount >= SECTOR_1MB, ...) at RegionFile.java:102 throws IllegalStateException with the message Chunk data is too large to fit in a region file, and it propagates out of saveChunk's catch (IOException) at AnvilLoader.java:401, so an oversized chunk simply cannot be saved at all. Falco writes the external file, sets ChunkCompression.EXTERNAL_FLAG = 0x80, and deletes a stale .mcc when 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, not 5 + N. The format defines it as the compression byte plus the payload. Minestom writes CHUNK_HEADER_LENGTH = 4 + 1 plus the payload (RegionFile.java:31, :99, written at :123) and compensates in its own reader by reading length - 1 bytes (: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#fromId accepts gzip, zlib and none, and rejects anything else with an explicit message rather than misreading it as another scheme. A world written with region-file-compression=lz4 cannot be read by this loader. That is a real limitation, taken deliberately to avoid an extra dependency [5].

Unknown blocks and biomes, and what "surviving a round trip" means here

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 DataVersion migration. MinecraftServer.DATA_VERSION is written into every saved chunk, but the stored DataVersion of a chunk being read is never inspected. Data from an older world version is interpreted with the current schema [5].
  • Diagnostics saturate. AnvilDiagnostics tracks at most MAX_TRACKED_NAMES = 64 distinct names per category, so once 64 distinct unknown block names have been seen, further distinct names are substituted silently and unknownBlockCount() 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.

Where this leaves the decision

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].


Sources

  1. 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 jar net.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.
  2. 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 .mcc mechanism at compression scheme + 128.
  3. 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 the palette / data layout of a section.
  4. 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.
  5. 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/.
  6. 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.
  7. HephaistosRegionFile.kt, named by Minestom's own RegionFile class 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.
  8. Aves — OneLiteFeather's utility library, https://github.com/OneLiteFeatherNET/Aves. The map provider that can be handed a ChunkLoaderFactory.

Clone this wiki locally