-
-
Notifications
You must be signed in to change notification settings - Fork 0
Anvil Chunk Loader
FalcoAnvilLoader is a net.minestom.server.instance.ChunkLoader implementation that reads and
writes chunks in the Anvil region file format (r.<x>.<z>.mca). It is a drop-in replacement for
net.minestom.server.instance.anvil.AnvilLoader and targets servers that load or save many chunks
concurrently, that need a read failure to stay visible instead of being silently replaced by a
freshly generated chunk, and that need to keep serving worlds containing blocks or biomes the
server does not know. It is not a general world-management layer: it handles the region/
directory of a single dimension and nothing else (see
What this loader does NOT do). All references in this document
point at the sources of this repository and at Minestom 2026.06.20-26.1.2. It is written for
someone deciding whether to adopt the loader and for someone reviewing the claims made for it; it is
not an API reference, which is what the Javadoc is for.
Read this before quoting a number. Every figure on this page comes from a JMH benchmark run on one machine, on one JVM, under one load, and none of them is an absolute statement about chunk loading. How the numbers were produced is in Benchmarking; why they are believable and what they do not license is in Rationale: Measurement, which also carries the one definition of what the
±means.
- Status
- Usage
- Architecture
- Comparison with the built-in AnvilLoader
- Which of those rows carry the argument
- Performance and memory — the measured tables, then time, memory and what is not faster
- What this loader does NOT do
- Error handling and world consistency
- Logging
- Testing
- References
Experimental. Every public type of
net.onelitefeather.falco.anvilis annotated@ApiStatus.Experimental. Signatures, class layout and behaviour may still change in a minor release. Do not rely on it in code you cannot adapt.
The annotation is present on all thirteen public types of the package —
FalcoAnvilLoader,
RegionFile,
RegionConstants,
ChunkCompression,
BitPacker,
PaletteData,
PaletteEntryResolver,
BlockPaletteResolver,
BiomePaletteResolver,
NbtReads,
SectionCodec,
AnvilDiagnostics,
AnvilChunkException.
(SectorAllocator is package-private and carries no annotation.)
Opt-in only. Nothing switches to this loader by itself. A server keeps using whatever loader it
uses today until it constructs a FalcoAnvilLoader explicitly.
import net.kyori.adventure.key.Key;
import net.minestom.server.MinecraftServer;
import net.minestom.server.instance.InstanceContainer;
import net.minestom.server.world.DimensionType;
import net.onelitefeather.falco.anvil.FalcoAnvilLoader;
import java.nio.file.Path;
public final class Bootstrap {
public static InstanceContainer createLobby() {
InstanceContainer instance = MinecraftServer.getInstanceManager()
.createInstanceContainer(DimensionType.OVERWORLD);
Key dimension = DimensionType.OVERWORLD.key();
FalcoAnvilLoader loader = new FalcoAnvilLoader(Path.of("worlds", "lobby"), dimension);
instance.setChunkLoader(loader);
instance.enableAutoChunkLoad(true);
return instance;
}
}FalcoAnvilLoader(Path worldRoot, Key dimension) takes the world root, not the region
directory. It resolves worldRoot/dimensions/<namespace>/<value>/region and falls back to
worldRoot/region when only the pre-26.1 layout exists
(FalcoAnvilLoader#resolveRegionDirectory).
The loader implements AutoCloseable. close() flushes and closes every open region file and
writes the summary line
(FalcoAnvilLoader#close,
FalcoAnvilLoader#logSummary).
Call it on server shutdown. During operation a region file is closed on its own once the last chunk
this loader read from it has been unloaded, and the number of region files held in the cache is
capped by DEFAULT_OPEN_REGION_LIMIT (64, configurable through the three-argument constructor).
A region file is never closed while a thread is reading from or writing to it. Every access
registers itself on the cached handle first; an unload, an eviction or close() only drops the
handle from the cache, and the thread that leaves it last performs the actual close. That is why a
chunk load cannot fail because another thread unloaded a chunk of the same region, and why a save
needs no retry when the open-file limit evicts its file mid-write. The cap therefore bounds the
number of cached files exactly; the number of open descriptors can exceed it for the duration of
a single access.
After close() the loader refuses further work with an IllegalStateException instead of ignoring
it: loadChunk, saveChunk and saveChunks all throw. Returning null from a closed loader would
report the chunk as absent and make the server generate a replacement over the stored data, and
ignoring a save would drop chunk data during the very shutdown it belongs to. A task that is already
past that check and reaches the region cache during the close either finds its handle still valid
and finishes normally, or is refused the same way — it can never publish a handle that nothing
closes again.
FalcoAnvilLoader is a plain net.minestom.server.instance.ChunkLoader, so anything that already
accepts one takes it without knowing this library exists. The two places a map provider can hand it
over are the ones Minestom offers to every loader — the constructor argument of
InstanceManager#createInstanceContainer(RegistryKey<DimensionType>, ChunkLoader) and
InstanceContainer#setChunkLoader(ChunkLoader):
import net.kyori.adventure.key.Key;
import net.minestom.server.MinecraftServer;
import net.minestom.server.instance.ChunkLoader;
import net.minestom.server.instance.InstanceContainer;
import net.minestom.server.registry.RegistryKey;
import net.minestom.server.world.DimensionType;
import net.onelitefeather.falco.anvil.FalcoAnvilLoader;
import java.nio.file.Path;
public final class MapLoaders {
public static InstanceContainer open(Path worldRoot, RegistryKey<DimensionType> dimensionKey) {
Key dimension = dimensionKey.key();
ChunkLoader loader = new FalcoAnvilLoader(worldRoot, dimension);
return MinecraftServer.getInstanceManager().createInstanceContainer(dimensionKey, loader);
}
}The relevant Falco signatures are:
| Member | Declaration |
|---|---|
FalcoAnvilLoader(Path, Key) |
public FalcoAnvilLoader(Path worldRoot, Key dimension) |
FalcoAnvilLoader(Path, Key, int) |
public FalcoAnvilLoader(Path worldRoot, Key dimension, int openRegionLimit) |
A provider that keeps a world root per map passes that root and the dimension key of the instance it
is building; the loader resolves the region directory from them itself. Any other ChunkLoader is
supplied the same way, which is what makes the choice reversible.
The falco-demo module does exactly this and is the worked example that is compiled on every build:
LoaderKind#create
constructs either loader from the same world description, and
DemoServer
hands the result to createInstanceContainer.
Both loading and saving are split into three stages. The point of the split is that no CPU-bound work happens while a lock is held. Decompression, NBT parsing, palette conversion and compression are the expensive parts of chunk IO; if they run inside a per-region lock, adding threads adds contention and nothing else, because every thread touching the same region file has to wait for them.
flowchart LR
subgraph load["loadChunk"]
direction LR
L1["1. IO<br/>region lock free<br/>positional read of raw bytes"]
L2["2. Codec<br/>no lock<br/>inflate, NBT parse, palette decode"]
L3["3. Apply<br/>chunk write lock<br/>copy into sections"]
L1 --> L2 --> L3
end
subgraph save["saveChunk"]
direction LR
S1["1. Snapshot<br/>chunk read lock<br/>clone sections, collect block entities"]
S2["2. Codec<br/>no lock<br/>palette encode, NBT write, deflate"]
S3["3. IO<br/>region lock<br/>allocate sectors, write header entry"]
S1 --> S2 --> S3
end
Plain text form:
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)
Concretely:
-
Load stage 1 —
RegionFile.readRawreturns the still-compressed payload and takes no lock; it usesFileChannel.read(ByteBuffer, long), which does not mutate the channel position and is therefore safe from several threads (RegionFile#readRaw,RegionFile#readEntry,RegionFile#readFully). Taking no lock does not mean reading whatever is on disk: a chunk which is rewritten releases its sector range, and the allocator may hand that range to the next write of any chunk while the reader is still inside it. Each of the 1024 entries therefore carries a version counter which a writer raises on entry to and on exit from its critical section. The reader takes the counter, rejects an odd one, reads the bytes and takes the counter again; anything but an unchanged even counter makes it start over, and after four attempts it falls back to the writer lock so a chunk which is rewritten in a loop cannot starve it (RegionFile#readRaw, the counters inRegionFile.versions, the four inRegionFile.OPTIMISTIC_ATTEMPTS). Readers are never serialised against each other and never delay a writer. -
Load stage 2 — decompression and NBT parsing happen in the caller
(
FalcoAnvilLoader#loadChunk).decodeSectionsthen returns aList<DecodedSection>(FalcoAnvilLoader#decodeSections), andloadChunkcalls it before it takes the chunk lock. Everything costly happens here: resolving every palette entry through the resolvers, deriving the bits per entry, validating the packed arrays and reading the light arrays. The result is a list of immutable records carrying the section index, the decoded block and biomePaletteDataand the two light arrays (FalcoAnvilLoader.DecodedSection). -
Load stage 3 — the chunk write lock is taken, each record is transferred via
DecodedSection.applyTo(chunk), the block entities are placed, and the lock is released (FalcoAnvilLoader#loadChunk,FalcoAnvilLoader#applyBlockEntities). The guarded region performs no parsing and no palette resolution at all — only writes intoSection.skyLight(),Section.blockLight(),Section.blockPalette()andSection.biomePalette()(FalcoAnvilLoader.DecodedSection#applyTo). This split is the concrete implementation of the pipeline: the record type exists purely to carry decoded state across the lock boundary. -
Save stage 1 — the chunk read lock is held only long enough to clone every
Sectionand collect the block entities (FalcoAnvilLoader#snapshot). -
Save stage 2 — encoding, NBT serialisation and deflate run on the clones, outside every lock
(
FalcoAnvilLoader#snapshot,FalcoAnvilLoader#encodeSection,FalcoAnvilLoader#saveChunk). -
Save stage 3 — the region lock covers sector allocation, the payload write, the 8-byte header
entry update and, for an oversized chunk, the rename which puts its external file in place. The
header entry decides which of the two storage locations a reader has to follow, so the entry and
the file have to change together; only the bytes of the external file are written before the lock
is taken, into a staging file next to the region file
(
RegionFile#writeRaw,RegionFile#placeExternal,RegionFile.STAGING_SUFFIX).
supportsParallelLoading() and supportsParallelSaving() both return true
(FalcoAnvilLoader#supportsParallelLoading,
FalcoAnvilLoader#supportsParallelSaving),
so InstanceContainer dispatches loads onto virtual threads
(Minestom InstanceContainer.java:362-372).
Every class in net.onelitefeather.falco.anvil has exactly one job, which is what makes
most of the package testable without a running server.
| Class | Single responsibility |
|---|---|
RegionConstants |
Layout constants of the region format and pure offset/index arithmetic. No state. |
SectorAllocator |
Tracks used sectors in a BitSet, first-fit allocation, reuse of freed ranges, overlap detection. No file access. |
RegionFile |
Byte container for one .mca file: header tables, sector placement, raw read/write, .mcc overflow. Knows nothing about NBT or Minestom. |
ChunkCompression |
The compression scheme byte: id mapping, external flag, compress/decompress. |
BitPacker |
Packing and unpacking of palette indices into long[], and derivation of bits-per-entry. Pure functions. |
PaletteData |
Immutable palette + packed indices pair; construction from disk, construction from raw values, unpacking. |
PaletteEntryResolver |
Interface between named format entries and numeric server ids. |
BlockPaletteResolver |
Block name/properties ↔ Minestom block state id, with air fallback. |
BiomePaletteResolver |
Biome name ↔ registry id, with plains fallback and lazily resolved registry. |
NbtReads |
Strict accessors for Adventure NBT: a missing or mistyped key is an error, not a default. |
SectionCodec |
Palette container (palette + data) ↔ PaletteData, for blocks and biomes. |
AnvilDiagnostics |
Throttling of repeated warnings and the counters reported on close. Thread safe. |
AnvilChunkException |
The unchecked failure signalling that an existing chunk could not be read. |
FalcoAnvilLoader |
Orchestration: region file cache, the three stages, block entities, logging, saveChunks scheduling. |
NbtReads exists because CompoundBinaryTag getters return defaults for missing or mistyped keys.
For chunk data that default is dangerous: a malformed region file would decode into an empty chunk
which then overwrites the real data on the next save
(NbtReads).
It also avoids the array-tag iterators of Adventure 5.1.1, which stop one entry early
(NbtReads#longArray,
NbtReads#intArray).
BiomePaletteResolver does not read the biome registry in its constructor. It stores a
Supplier<DynamicRegistry<Biome>> and resolves it on first use, behind double-checked locking on a
volatile field holding a private Registries record
(BiomePaletteResolver.registrySupplier,
BiomePaletteResolver.resolved,
BiomePaletteResolver#registries).
The record pairs the registry with the id of the fallback biome, so both are published together by
a single volatile write
(BiomePaletteResolver.Registries).
The reason is a hard ordering constraint. MinecraftServer.getBiomeRegistry() dereferences the
static serverProcess field (Minestom MinecraftServer.java:280), which stays null until
MinecraftServer.init(..) assigns it through updateProcess (MinecraftServer.java:85-88,
:95-99). Calling it earlier throws. Resolving the registry in the constructor would therefore make
a loader impossible to construct before MinecraftServer.init(..) has run — which is exactly when
worlds and map providers are normally set up. Deferring the lookup to the first decoded biome
palette means the loader can be constructed at any point during startup, and the registry is read
only once a chunk is actually being loaded.
The same constraint is what makes the Minestom AnvilLoader hard to use early and hard to unit
test: it reads the registry in a static initialiser (instance/anvil/AnvilLoader.java:46-48), so
merely referencing the class before init fails. The two-argument constructor of
BiomePaletteResolver exists so a test can inject a registry without a server
(BiomePaletteResolver(AnvilDiagnostics, Supplier)).
BlockPaletteResolver needs no such treatment: Block.fromKey and Block.fromStateId are static
registry lookups performed per palette entry, not cached at class initialisation
(BlockPaletteResolver#toId,
BlockPaletteResolver#toEntry).
How the two sides are referenced. Minestom references are a path relative to
net/minestom/server/ plus a line number, at version 2026.06.20-26.1.2. Falco references name the
member instead — RegionFile#writeRaw for a method, RegionFile.OPTIMISTIC_ATTEMPTS for a field or
constant, a bare type name for the type itself — and every Falco type lives under
falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ unless the reference says otherwise.
The asymmetry is deliberate, not an oversight. The Minestom version is pinned, so those files will
never move again and the line number is the most precise pointer available. The Falco sources are the
sources of this branch and change under active work: the concurrency fixes that introduced the
per-entry seqlock and the region handle use count grew FalcoAnvilLoader by roughly four hundred
lines in a single week, which silently pointed every line number in this document at a blank line or
a stray */. A member name survives everything short of a rename, and a rename at least breaks
loudly.
| Aspect | Minestom AnvilLoader
|
Falco FalcoAnvilLoader
|
Impact |
|---|---|---|---|
| Chunk length field | Writes 4 + 1 + N: CHUNK_HEADER_LENGTH = 4 + 1 (instance/anvil/RegionFile.java:31), chunkLength = CHUNK_HEADER_LENGTH + dataBytes.length (:99), file.writeInt(chunkLength) (:123). |
Writes 1 + N: int length = COMPRESSION_FIELD_SIZE + stored.length, then buffer.putInt(length) (RegionFile#writeRaw, RegionConstants.COMPRESSION_FIELD_SIZE). |
The format defines the field as compression byte + payload. Minestom's own reader compensates by reading length - 1 bytes (RegionFile.java:84), so its files are self-consistent, but every chunk it writes declares four bytes more than it holds. A spec-conforming reader over-reads up to four bytes of sector padding, and when the payload ends within four bytes of a sector boundary it reads past the allocation. Falco writes the value the format specifies. |
| Short reads |
file.read(data) — the return value is discarded (instance/anvil/RegionFile.java:85). RandomAccessFile.read may return fewer bytes than requested. |
readFully loops until the buffer is full and reports EOF as an IOException (RegionFile#readFully), used for both the header (RegionFile#readHeader) and the payload (RegionFile#readEntry). |
A short read in Minestom leaves the tail of data zero-filled and is then handed to the NBT parser, producing a parse error or a truncated chunk with no indication of the cause. Falco either has the full payload or fails with the byte counts in the message. |
| Status key casing | Reads "status" (instance/anvil/AnvilLoader.java:133) and writes "status" (:396). The vanilla key is Status. |
Reads Status first and falls back to status (FalcoAnvilLoader.STATUS_KEY, FalcoAnvilLoader.LEGACY_STATUS_KEY, FalcoAnvilLoader#isFullyGenerated); writes Status (FalcoAnvilLoader#snapshot). |
For a vanilla world Minestom's getString("status") returns the empty default, which the status.isEmpty() branch (AnvilLoader.java:135) treats as fully generated — so partially generated vanilla chunks are loaded as if complete, and the warning at :142 never fires for them. Its own output carries a key vanilla ignores. Falco reads both spellings and writes the vanilla one. |
| Read failure handling |
catch (Exception e) { handleException(e); return null; } (instance/anvil/AnvilLoader.java:117-120). |
Logs with context, reports to the exception manager and rethrows as AnvilChunkException (FalcoAnvilLoader#failedLoad). |
null means "chunk absent" to InstanceContainer, which then generates a replacement (instance/InstanceContainer.java:336-343) that overwrites the unreadable-but-intact data on the next save. Throwing makes InstanceContainer complete the load future exceptionally instead (:367-372), so the stored bytes are left untouched. |
| Unknown block |
Objects.requireNonNull(Block.fromKey(blockName), "Unknown block " + blockName) (instance/anvil/AnvilLoader.java:263). |
BlockPaletteResolver.toId substitutes Block.AIR.stateId() and reports the name once (BlockPaletteResolver#toId). |
In Minestom one modded or newer-version block name throws an NPE out of loadSections, which is swallowed at :117-120; the whole chunk is then regenerated and lost on the next save. Falco loses one block state and keeps the chunk. |
| Unknown biome | Falls back to PLAINS_ID with no report at all (instance/anvil/AnvilLoader.java:294-296). |
Falls back to plains and reports the name once through the diagnostics (BiomePaletteResolver#toId). |
Same resulting data, but in Minestom a world referencing biomes the registry does not have is rewritten to plains silently. Falco leaves a log entry and a counter. |
| Lock granularity while loading | One ReentrantLock per region file (instance/anvil/RegionFile.java:42); readChunkData holds it across seek, the length/compression read, the payload read and the decompression plus NBT parse (:67-92, parse at :88). |
readRaw holds no lock and uses positional channel reads (RegionFile#readRaw); inflate and NBT parse run in the caller (FalcoAnvilLoader#loadChunk), palette decoding before the chunk lock (FalcoAnvilLoader#decodeSections). |
Minestom serialises the expensive part of every load of the same region behind one lock, so supportsParallelLoading() == true yields little for chunks in one region file. In Falco only the byte read touches the file, and it needs no mutual exclusion. |
| Lock granularity while saving | The chunk write lock is held across the entire serialisation loop for all sections: palettes, block entities, biome lookups, packing (instance/anvil/AnvilLoader.java:420-519). Compression itself is outside the region lock (instance/anvil/RegionFile.java:96-98 before :104). |
The chunk read lock is held only to clone the sections and collect block entities (FalcoAnvilLoader#snapshot); everything after that works on the clones (FalcoAnvilLoader#encodeSection, FalcoAnvilLoader#saveChunk). |
Taking the write lock blocks readers as well as writers, for the full duration of encoding a chunk. A read lock over an array of Section.clone() calls keeps the chunk readable while it is being serialised. |
saveChunks default |
AnvilLoader does not override it, so ChunkLoader.saveChunks applies: one virtual thread per chunk, coordinated by a Phaser (instance/ChunkLoader.java:62-82). The catch branch (:71-73) skips phaser.arriveAndDeregister(). |
Overridden: chunks are grouped by region index, one task per region, concurrency bounded by a Semaphore of max(Runtime.availableProcessors(), 2) permits (FalcoAnvilLoader#saveChunks, FalcoAnvilLoader.saveLimit), results collected in awaitAll (FalcoAnvilLoader#awaitAll). |
With a Throwable escaping saveChunk the registered party is never deregistered, so phaser.arriveAndAwaitAdvance() at ChunkLoader.java:76 never advances and the saving thread blocks for good. Independently, one thread per chunk means every chunk of a region contends for that region's lock while all snapshots are alive at once. Grouping by region removes the contention and the semaphore bounds peak memory. |
| Chunks over 255 sectors |
Check.stateCondition(sectorCount >= SECTOR_1MB, "Chunk data is too large to fit in a region file") (instance/anvil/RegionFile.java:102, SECTOR_1MB = 256 at :29), which throws IllegalStateException (utils/validate/Check.java:58-62). |
Payload is written to c.<x>.<z>.mcc next to the region file, the location entry stores an empty payload and the compression byte carries EXTERNAL_FLAG = 0x80 (RegionFile#writeRaw, RegionFile#externalPath, ChunkCompression.EXTERNAL_FLAG). Reading follows the flag (RegionFile#readEntry). |
A chunk larger than ~1 MiB compressed cannot be saved at all by Minestom; the exception propagates out of saveChunk's IOException-only catch (AnvilLoader.java:402). Falco uses the external-file mechanism the format defines and deletes a stale .mcc when a chunk shrinks again, inside the same critical section which rewrites the header entry (RegionFile#writeRaw, RegionFile#removeExternal). |
| Block entities in single-value sections | Block entities are collected only inside the getAll callback of the non-uniform branch (instance/anvil/AnvilLoader.java:456-468); when section.blockPalette().singleValue() != -1 that branch is skipped entirely (:436-441). |
collectBlockEntities walks every block position of the chunk independently of the palette shape (FalcoAnvilLoader#collectBlockEntities). |
A section whose blocks all share one state id but where some carry NBT or a handler — for example a section of air with handler-marked positions — loses all of its block entities on save in Minestom. |
| Palette bits-per-entry on load |
Palette.load(palette, values) derives bits-per-entry from palette.length alone and ignores values.length (instance/palette/PaletteImpl.java:127-132), called at instance/anvil/AnvilLoader.java:234 and :248. |
Derived from the palette size, then verified against the actual long[] length (PaletteData#read, BitPacker#resolveBitsPerEntry); if the two disagree the data is unpacked with the resolved width and written entry by entry (FalcoAnvilLoader#apply). |
The format permits a writer to use a wider bits-per-entry than the palette size requires. Minestom decodes such a section with the wrong stride, producing wrong blocks with no error. Falco detects the mismatch from the array length and decodes with the width that actually fits. |
| Palette deduplication on save | Linear search per block: blockPaletteIndices.indexOf(value) on an IntArrayList (instance/anvil/AnvilLoader.java:447); same for biomes with biomePalette.indexOf(biomeName) on an ArrayList<BinaryTag> (:484). |
PaletteData.encode assigns indices via HashMap.computeIfAbsent (PaletteData#encode). |
Minestom's per-section cost is O(n·m) for n = 4096 blocks and m = distinct states in the section (biomes: O(64·m) with a deep BinaryTag equality per probe). Falco is O(n) hash lookups. This is a structural difference in the algorithm, not a measured figure. |
| Registry access at class initialisation | Static fields read the biome registry and the block state count during class init: BIOME_REGISTRY = MinecraftServer.getBiomeRegistry(), PLAINS_ID, new CompoundBinaryTag[Block.statesCount()] (instance/anvil/AnvilLoader.java:46-48). |
The biome registry is resolved lazily on first use behind a volatile field, and the supplier is injectable (BiomePaletteResolver.resolved, BiomePaletteResolver(AnvilDiagnostics, Supplier), BiomePaletteResolver#registries). Block lookups go through Block.fromKey per palette entry (BlockPaletteResolver#toId). |
Merely referencing AnvilLoader before the server registries exist fails in the static initialiser, so the class cannot be constructed during early startup and unit tests must boot a server. In Falco the loader can be constructed before the registries are populated, and the resolver can be tested with a supplied registry. |
| Logging and diagnostics | Unthrottled per-chunk WARN for partially generated chunks (instance/anvil/AnvilLoader.java:142), per-tag WARN for invalid sections (:203), block entity tags (:304) and non-string block properties (:273-276). No counters, no summary. |
AnvilDiagnostics admits only the first occurrence of a distinct name and caps the tracking sets at MAX_TRACKED_NAMES = 64 (AnvilDiagnostics.MAX_TRACKED_NAMES, AnvilDiagnostics#track); a partial chunk reports once per distinct Status value under the same cap (AnvilDiagnostics#reportPartialChunk) and a section outside the world is logged at TRACE (FalcoAnvilLoader#decodeSections). A summary line is written on close (FalcoAnvilLoader#logSummary). |
A world with many partial chunks or one unknown modded block produces one log line per chunk in Minestom, which buries everything else. In Falco the same condition produces one line per distinct value plus a counter, and the cap keeps a corrupt world from growing the tracking sets without bound. |
| Header write per chunk |
writeHeader rewrites the whole 8192-byte header on every dirty save (instance/anvil/RegionFile.java:182-196, called at :131). |
writeEntry writes only the 4-byte location and the 4-byte timestamp of the affected index (RegionFile#writeEntry). |
Minestom rewrites 1024 location and 1024 timestamp entries to change one of each. Beyond the write volume, a crash during that rewrite can damage entries of unrelated chunks; an 8-byte update cannot. |
| Region header validation |
readHeader marks every non-zero location in the bitset, checking only that it stays inside the current sector count (instance/anvil/RegionFile.java:167-172, :234-239). Overlapping entries are accepted. |
Entries pointing into the header or with a zero sector count are dropped (RegionFile#readHeader) and SectorAllocator.reserve rejects an overlapping range with the conflicting sector in the message (SectorAllocator#reserve). |
Two location entries claiming the same sectors stay undetected in Minestom until one chunk overwrites the other. Falco fails to open such a file with a message naming the sector. |
| NBT strictness | Uses the defaulting getters throughout: sectionData.getCompound("block_states") returns an empty compound when absent (instance/anvil/AnvilLoader.java:239), and an empty palette list then leaves the section untouched (:242-249). |
NbtReads reports a missing or mistyped key as an IOException naming the key, the expected type and the actual type (NbtReads#longArray, NbtReads#missing); SectionCodec rejects empty palettes (SectionCodec#decode, SectionCodec#decodeBiomes). |
In Minestom a truncated or malformed section silently loads as untouched (air) and is written back that way. In Falco the same input fails the load, so the stored bytes survive. |
| Region file lifecycle | Opened inside alreadyLoaded.computeIfAbsent(...), i.e. blocking file IO inside a ConcurrentHashMap mapping function (instance/anvil/AnvilLoader.java:179-194); closed when the last chunk of the region unloads (:557-584). |
Opened outside the mapping function, published with putIfAbsent, and a losing race closes the redundant handle (FalcoAnvilLoader#acquireRegion); a file is closed once the last chunk this loader loaded is unloaded, with a hard cap on open files as a backstop (FalcoAnvilLoader.DEFAULT_OPEN_REGION_LIMIT); a handle in use is only dropped from the cache and closed by its last user. |
computeIfAbsent holds the bin lock for the duration of the mapping function; performing file IO there blocks other keys hashing to the same bin. Also, unloadChunk is called for chunks the loader never loaded (documented at instance/ChunkLoader.java:102-108), which makes a plain reference count unreliable — Falco therefore tracks only the chunks it loaded itself and additionally caps the number of open files. |
| Instance-level and unknown chunk tags |
loadInstance/saveInstance read and write level.dat (instance/anvil/AnvilLoader.java:96-107, :332-343). Chunk tags other than Heightmaps, sections and block_entities are kept in the chunk tag handler (:144-151) and written back on save (:390); heightmaps are restored (:140). |
Neither method is overridden. snapshot builds a fixed set of keys: DataVersion, xPos, zPos, yPos, Status, LastUpdate, sections, block_entities (FalcoAnvilLoader#snapshot). |
This one favours Minestom. Saving a vanilla chunk with the Falco loader drops Heightmaps, structures, block_ticks, fluid_ticks, PostProcessing and any other chunk-level tag, and level.dat is not touched at all. See the next section. |
Twenty rows. Every reference above was read in the sources of the stated versions.
The table is flat by construction — every row gets one line, whether it changes what a server does or tidies a log message. Sorted by consequence they fall into four groups.
Silent data loss. The rows that change what ends up on disk: block entities dropped from uniform
sections, the palette stride derived from the palette length alone, the discarded return value of
read, the length field written four bytes too large. None of these announce themselves — the world
loads, the chunk looks fine, and the damage surfaces later or in another tool. These are the reason
the loader exists.
Failure that destroys the original. A read error returning null means "chunk absent" to
InstanceContainer, which generates a replacement and overwrites the intact-but-unreadable bytes on
the next save. This one row turns a recoverable problem into an unrecoverable one.
Concurrency. Both loaders report supportsParallelLoading() == true. Only one of them means it.
This is the group where the two implementations diverge furthest under measurement — far enough that
on three of the four thread counts Minestom's read carries no usable factor at all and the finding
has to be stated as a loss of predictability rather than as a number. The diagrams below are about
it, and the tables that bound it are further down under
Performance and memory.
Everything else — logging volume, registry access at class-initialisation time, header write volume — is real but bounded. A server survives all of it.
Both loaders do the same work per chunk: read bytes, inflate, parse NBT, decode palettes. The difference is which of those steps happens while the region file's lock is held.
flowchart LR
subgraph mine["Minestom · RegionFile.readChunkData"]
direction TB
M1["seek + read length"]
M2["read payload"]
M3["inflate"]
M4["parse NBT"]
M1 --> M2 --> M3 --> M4
end
subgraph falco["Falco · RegionFile.readRaw + caller"]
direction TB
A1["positional read"]
A2["inflate"]
A3["parse NBT"]
A4["decode palettes"]
A1 --> A2 --> A3 --> A4
end
In Minestom all four steps sit inside one ReentrantLock held per region file
(instance/anvil/RegionFile.java:42, parse at :88). In Falco only the first one touches the file,
and it needs no mutual exclusion at all: FileChannel.read(ByteBuffer, position) does not move the
channel position, so two readers of different chunks do not interfere. Inflate, parse and palette
decode run in the caller.
The consequence appears as soon as two threads want chunks from the same region — which is exactly what loading a spawn area does, since a region file holds 32×32 chunks:
sequenceDiagram
participant T1 as Thread 1
participant T2 as Thread 2
participant R as Region file
Note over T1,R: Minestom — the lock spans the expensive part
T1->>R: acquire lock
T1->>R: read bytes, inflate, parse NBT
T2->>R: acquire lock — blocked for all of it
R-->>T1: release
R-->>T2: granted
T2->>R: read bytes, inflate, parse NBT
sequenceDiagram
participant T1 as Thread 1
participant T2 as Thread 2
participant R as Region file
Note over T1,R: Falco — only the byte read is ordered
T1->>R: positional read
T2->>R: positional read, concurrent
R-->>T1: bytes
R-->>T2: bytes
T1->>T1: inflate, parse, decode
T2->>T2: inflate, parse, decode
Since inflate and NBT parsing dominate the load path, putting them inside the lock means extra threads mostly queue. That is what "nominal parallelism" means here, and the next section measures it.
The same question on the write side, with a different answer. Minestom holds the chunk's write
lock across the serialisation of every section — palettes, block entities, biome lookups, packing
(instance/anvil/AnvilLoader.java:420-519). A write lock excludes readers as well as writers, so
for the whole duration of encoding, nothing else may look at that chunk.
Falco takes the read lock, clones the sections, and releases it. Everything after that works on copies:
flowchart TB
subgraph mineS["Minestom · saveChunk"]
direction TB
MW["chunk WRITE lock"]
MW --> MS["encode all sections<br/>palettes, block entities, biomes, packing"]
MS --> MR["release"]
MB["other readers of this chunk: blocked throughout"]
MS -.-> MB
end
subgraph falcoS["Falco · saveChunk"]
direction TB
AR["chunk READ lock"]
AR --> AC["clone sections"]
AC --> AU["release"]
AU --> AE["encode + deflate on the clones<br/>no chunk lock held"]
AB["other readers of this chunk: admitted"]
AC -.-> AB
end
AnvilLoader does not override saveChunks, so the interface default applies: one virtual thread
per chunk, coordinated by a Phaser (instance/ChunkLoader.java:62-82). Its catch branch
(:71-73) returns without calling phaser.arriveAndDeregister().
flowchart TB
subgraph mineB["Minestom · ChunkLoader.saveChunks default"]
direction TB
P["phaser.register() per chunk"]
P --> TH["one virtual thread per chunk — unbounded"]
TH --> OK["success: arriveAndDeregister"]
TH --> ERR["exception: caught, NOT deregistered"]
OK --> W["arriveAndAwaitAdvance"]
ERR --> HANG["party never arrives<br/>saving thread blocks for good"]
end
subgraph falcoB["Falco · saveChunks"]
direction TB
G["group chunks by region index"]
G --> S["one task per region,<br/>bounded by a Semaphore"]
S --> C["collect every result in awaitAll"]
C --> F["a failure surfaces as a failed future"]
end
Two independent problems in one row. The Phaser branch is a liveness bug: a single Throwable
escaping saveChunk blocks the saving thread permanently. The unbounded thread-per-chunk is a
memory one: every chunk of a region contends for that region's lock while all snapshots are alive at
once. Grouping by region removes the contention, and the semaphore bounds the peak.
Two kinds of statement appear below. The structural differences are visible in the source of both
implementations and are marked as such; they need no benchmark and cannot be argued away as noise.
The measured ones come from the JMH benchmarks in falco-benchmarks/src/jmh — how to re-run them
is in Benchmarking — and each table carries the configuration it was produced under.
Where a cost is called dominant without a benchmark name attached, it comes from a one-off
micro-measurement taken while designing the loader and no run record was kept; treat those as orders
of magnitude and nothing finer.
Every measured figure below comes from a class annotated @Fork(1), and every table below was
measured at that setting, so the ± after a mean describes the spread between the measurement
iterations of a single JVM process and says nothing about how the number would move across JVM
launches. There is exactly one exception on this page, and it is called out where it appears: the
four-thread control run under
Measured: concurrent readers of one region file
was run with -f 2, so its ± does cover the difference between two JVM launches. The one
definition of that quantity, and what it does and does not bound, is in
Rationale: Measurement; it is stated there once rather than repeated here.
Two facts shaped every decision below. In the load path, zlib inflate plus NBT parsing dominate — palette handling is a small fraction of the total. In the save path, deflate dominates everything else. Both come from the design-time micro-measurements described above rather than from a published table, so they are the reason for a decision and not evidence for a factor. Optimising the palette would therefore have been pointless; keeping compression and parsing out of the locks is where the time actually is.
RegionFileComparisonBenchmark measures the region file of Falco against the one Minestom ships with,
on the same stored bytes, through the same Adventure writer at the same compression level, from a
stored chunk to a parsed compound. It lives in net.minestom.server.instance.anvil because
Minestom's RegionFile is package-private — the same reason the light comparison lives in Minestom's
light package. Minestom's AnvilLoader itself cannot be reached from a benchmark fork at all: its
static fields read the biome registry and the block state count, so the class initialiser fails
before any measurement starts. The region file reads no registry and is measurable directly.
java -jar build/libs/falco-*-jmh.jar "RegionFileComparisonBenchmark.(falco|minestom)Read" \
-f 1 -wi 3 -i 5 -t <threads> -p distinctStates=200
| Threads | Falco | Minestom | What the row supports |
|---|---|---|---|
| 1 | 1 089 ± 48 µs/op | 1 045 ± 112 µs/op | Intervals overlap over [1 041, 1 137] against [933, 1 157]. No difference is resolvable at this precision, in either direction. |
| 2 | 1 174 ± 71 µs/op | 103 437 ± 856 306 µs/op | Minestom's half-width is 8.3× its mean. No factor may be quoted. What is established is that its read time stops being predictable. |
| 4 | 1 370 ± 200 µs/op | 302 704 ± 674 429 µs/op | Half-width 2.2× the mean. Same reading as the row above, no factor. |
| 8 | 2 282 ± 248 µs/op | 297 075 ± 593 563 µs/op | Half-width 2.0× the mean. Same reading, no factor. |
RegionFileComparisonBenchmark.falcoRead / .minestomRead, distinctStates = 200, one thread
count per row via -t (JMH takes one -t per run, so the four rows are four separate runs), one
fork, 3 warmup and 5 measurement iterations of 1 s as the class annotates them, -Xms1g -Xmx1g, 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.
RegionFileComparisonBenchmark was run twice in this form, and the table above is the second of
the two runs. The first is the four-row table
Project Status owns and
Benchmarking reproduces; that first run is the canonical one, it is where a
correction is made, and the table above is an independent second run published only on this page.
Neither supersedes the other and neither is an erratum for the other — they are two measurements of
the same thing on two different days.
The two agree on Falco at every thread count and disagree wildly on Minestom: at one thread 1 060 ± 55 there against 1 045 ± 112 µs/op here, at two 2 200 ± 445 against 103 437 ± 856 306, at four 11 021 ± 16 470 against 302 704 ± 674 429 — and at eight the run that measured the higher of the two swaps over, 530 905 ± 1 928 261 there against 297 075 ± 593 563 here. That last clause is about which run came out higher, not about which loader: Minestom's mean is above Falco's at two, four and eight threads in both runs. Neither run's date was recorded and the two provenance lines describe the same configuration, so nothing distinguishes them but the state of the machine on the day.
That disagreement is itself the result. No row of one table may be held against a row of the other, and the magnitude of Minestom's collapse is not a quantity this project has measured. Two things do reproduce across both runs, and they are what the design rests on. The direction: at two, four and eight threads Minestom's mean is the higher of the two in both runs, and at one thread it is the lower in both. And the loss of predictability: at four and at eight threads Minestom's half-width exceeds its own mean in both runs, while Falco's stays inside 15 % of its own at every point of both.
The row that carries the argument is a separate, two-fork run, because it is the only one here
that does not rest on a single JVM. Repeated at four threads with two forks and ten iterations, which
tightens the spread considerably: Falco 1 325.6 ± 21.1 µs/op against Minestom
359 690.8 ± 97 498.3 µs/op. Both intervals — [1 304.5, 1 346.7] and [262 192.5, 457 189.1] —
are disjoint, and separated by more than two orders of magnitude, so the direction is not in
question. Minestom's half-width is 27 % of its mean, which is what bounds how precisely the gap may
be stated: the two intervals put
it between roughly 190× and 350×, and no more precisely than that. That bound is a property of this
one session and of nothing else: the two single-fork runs measured the same four-thread Minestom read
at 11 021 ± 16 470 and at 302 704 ± 674 429 µs/op, so the magnitude moves by more than an order of
magnitude between sessions. The point estimate is not the finding, and neither is the bound; the
finding is that the read time stops being predictable.
RegionFileComparisonBenchmark.falcoRead / .minestomRead, distinctStates = 200, -t 4, two
forks, ten measurement iterations, warmup unrecorded, -Xms1g -Xmx1g, JMH 1.37, one 16-core machine
recorded as not idle, no results.json committed. Two forks: the ± here does cover the difference
between two JVM launches, which no other table on this page does.
Four things this measurement says, including the ones that do not flatter Falco:
- On one thread there is no advantage, and none is claimed. The two means differ by 4 % and the intervals overlap; that is not evidence that Falco is slower, and it is not evidence that the two are equal either. It is a row on which the measurement resolves nothing. The design pays off under contention and nowhere else.
-
Falco degrades gently. From one to eight threads its mean grows by roughly a factor of 2, from
1 089 to 2 282 µs/op, which is what sharing a disk looks like. That comparison is between two
separate JMH sessions — JMH takes one
-tper run — so the two intervals do not compose into a bound and none is quoted; what the four rows support is the shape, not a factor between them. Minestom does not degrade in this run; its time stops being predictable at all. - Minestom's three contended rows cannot carry a factor and none is printed. A half-width larger than the mean means the measurement does not constrain the value: the lower end of every one of those three intervals is negative. Loss of predictability under contention is the finding, and it is a stronger one than a number would be, because it is what an operator actually experiences.
- The size of the collapse is not explained. Pure serialisation of a 1 045 µs operation across four threads predicts roughly 4 200 µs, not 360 000 — and that comparison itself mixes two runs, so it is an order-of-magnitude observation and not a derived quantity. The direction reproduces across the single-fork and the two-fork run; the mechanism behind the magnitude has not been investigated, and lock convoying, scheduler interaction and background load on a machine recorded as not idle are all still open. Do not quote the factor as if it were understood.
The same measurement as a picture. On one thread the two loaders sit on top of each other; from two threads onwards one of them stays roughly where it was and the other leaves the chart.
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#56B4E9, #E69F00"}}}}%%
xychart-beta
title "Reading one region file: Falco (flat, along the bottom) against Minestom (climbing)"
x-axis "Threads reading from the same region file" [1, 2, 4, 8]
y-axis "Microseconds per read, lower is better" 0 --> 320000
line [1089, 1174, 1370, 2282]
line [1045, 103437, 302704, 297075]
xychart-beta cannot draw a legend, so it has to be said instead: the line running along the bottom
is Falco (blue in every chart of this document), the one climbing away from it is Minestom
(orange). Falco is not actually flat there. It only looks flat because the scale has to reach 300 000
to fit the other line at all. Its own shape is the next chart, and it is the same four numbers.
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#56B4E9, #E69F00"}}}}%%
xychart-beta
title "The same four Falco numbers, on a scale that fits them"
x-axis "Threads reading from the same region file" [1, 2, 4, 8]
y-axis "Microseconds per read, lower is better" 0 --> 2500
line [1089, 1174, 1370, 2282]
Why the first chart looks like that. Picture a shop with a single till. Minestom's region file has one lock per file, and it holds that lock not only while it fetches the bytes from disk, but also while it unpacks them and reads the structure inside them — and the unpacking and the reading are nearly all of the work. So one customer occupies the till for the entire purchase, and everybody else stands in the queue. Adding threads adds people to the queue; it does not add tills. Falco holds the lock only for fetching the bytes and does the unpacking and reading outside it, so several threads are served at the same time. That is also why the second chart still rises rather than staying level: those threads do share one disk, and going from one to eight of them costs roughly a factor of 2 — but they spend that time working, not waiting.
Two things the charts are not allowed to imply. First, a drawn line is a mean without its spread, and at two threads the Minestom measurement is 103 437 ± 856 306 µs/op — an uncertainty eight times the value itself. That point says "sometimes catastrophic", not "this is what it costs"; the four-thread control run with two forks is the trustworthy one. Second, standing in a queue does not explain how high the line goes, as the third bullet above says.
ChunkSaveComparisonBenchmark runs the whole save path of both loaders over the same chunk, varying
how many distinct block states a section holds — the axis on which the palette deduplication differs
(linear scan per block against a hash lookup). Both loaders are measured as they ship, and that
includes a compression level they do not share: Minestom writes at zlib level 6, Falco at
ChunkCompression.DEFAULT_LEVEL, which is 2
(ChunkCompression.DEFAULT_LEVEL,
ChunkSaveComparisonBenchmark.MINESTOM_LEVEL). Not equalising it is deliberate — the level is part
of what a user of the loader actually gets — but it means the difference below is not attributable
to the palette algorithm alone. The class ships compressFalcoLevel and compressMinestomLevel
for exactly that reason: they measure the level's own contribution so it can be subtracted.
The table below is the only published output of ChunkSaveComparisonBenchmark, and it is half of
the class. Four methods over five distinctStates levels make twenty configurations. The ten
belonging to falcoSave and minestomSave are below, drawn as five rows because each row is one
Falco figure against its Minestom counterpart — Benchmarking and
Project Status count those same measurements as the five published rows, which is
the same half of the class described two ways. The ten configurations belonging to
compressFalcoLevel and compressMinestomLevel have never been run into a published figure, which
is why the subtraction above has not been performed on any row here. Where the rest of this
documentation calls part of this class unpublished, that is the part it means.
java -jar build/libs/falco-*-jmh.jar "ChunkSaveComparisonBenchmark.(falco|minestom)Save" -f 1 -wi 3 -i 6
| Distinct states | Falco | Minestom | What the row supports |
|---|---|---|---|
| 1 | 968 ± 52 µs/op | 918 ± 39 µs/op |
[916, 1 020] against [879, 957] — overlap. No difference resolvable. |
| 16 | 3 826 ± 279 µs/op | 3 959 ± 227 µs/op |
[3 547, 4 105] against [3 732, 4 186] — overlap. No difference resolvable. |
| 64 | 5 555 ± 305 µs/op | 6 435 ± 421 µs/op |
[5 250, 5 860] against [6 014, 6 856] — disjoint. Falco faster, conservatively 1.03× to 1.31×. |
| 256 | 11 427 ± 980 µs/op | 12 095 ± 1 326 µs/op |
[10 447, 12 407] against [10 769, 13 421] — overlap. No difference resolvable. |
| 1 024 | 41 361 ± 4 082 µs/op | 47 273 ± 3 964 µs/op |
[37 279, 45 443] against [43 309, 51 237] — overlap. No difference resolvable. |
ChunkSaveComparisonBenchmark.falcoSave / .minestomSave, distinctStates as the first column,
one thread (there is no -t, and thread count is a CLI-only axis in this harness), one fork, 3
warmup and 6 measurement iterations of 2 s — the measurement count overrides the class annotation,
which specifies 5 — -Xms2g -Xmx2g, JMH 1.37, one 16-core machine recorded as not idle, no
results.json committed. Compression levels differ between the two sides by design, see above. One
fork: the ± covers variance between iterations of one JVM, not between JVM launches — see
Rationale: Measurement.
Here the two lines lie almost on top of each other — and that, rather than a gap, is the finding.
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#56B4E9, #E69F00"}}}}%%
xychart-beta
title "Saving one chunk: Falco (blue) against Minestom (orange)"
x-axis "Different block states in one section" [1, 16, 64, 256, 1024]
y-axis "Microseconds per save, lower is better" 0 --> 50000
line [968, 3826, 5555, 11427, 41361]
line [918, 3959, 6435, 12095, 47273]
No legend again: at the right-hand end the lower of the two lines is Falco (blue) and the upper one is Minestom (orange). Towards the left they are hard to tell apart, and at the very first point Minestom is the lower of the two.
Why so little happens here. Saving a chunk is mostly one single job: squeezing the data small
before it is written. Both loaders hand that job to the same library, so most of the time is the same
work on both sides. What differs is how each of them writes down the list of block types a section
contains. Minestom searches the list it has built so far once for every single block; Falco keeps a
lookup table and asks it directly — the difference between paging through a book for a word and going
to its index. That is a real structural difference, verifiable in the source of both without any
benchmark: IntArrayList.indexOf per block at instance/anvil/AnvilLoader.java:447 against
HashMap.computeIfAbsent in PaletteData#encode. The more different block types a section holds,
the more often it matters. But it is a small part of a large job, and no arrangement of it changes
what the squeezing costs.
What the chart must not be read as saying. Its lines are means, and every point also carries a spread that a line chart cannot draw. At four of the five points that spread is wider than the gap you can see, so the chart draws a difference the measurement does not establish.
This is a far smaller effect than the read path and it has to be read conservatively. Exactly one
of the five rows resolves a difference: at 64 distinct states the intervals are disjoint and Falco
is between 1.03× and 1.31× faster. The other four overlap, including the one at 1 024 states where
the means are 14 % apart — [37 279, 45 443] and [43 309, 51 237] share the range 43 309 to
45 443, so that row establishes nothing, in either direction. At one distinct state the means put
Minestom ahead and the intervals overlap, so that row establishes nothing either.
Two consequences follow, and neither is comfortable. The O(n·m) against O(n) difference in the
palette is real as a property of the two algorithms, but at these palette sizes this table does not
resolve it — five points, one of which separates. And the one row that does separate is confounded:
Falco compresses at level 2 and Minestom at level 6 in the same measurement, and the level's
contribution has not been subtracted. Closing this gap costs one run of compressFalcoLevel and
compressMinestomLevel, which the class already ships.
The compression level is a configuration choice available to either loader rather than a property
of this one, and the benchmark ships the pair of methods that would isolate it — compressFalcoLevel
against compressMinestomLevel. Shipping them is not the same as having run them: neither has been
run into a published figure, so the isolation is available and has not been performed.
At 256 distinct states the level-2 default of this loader is reported to compress roughly 2.4× faster than level 6, and 3.1× faster at 1 024, for roughly 3 % more stored bytes. No run record was kept for those three figures: no intervals, no iteration counts, no machine. They are the reason level 2 was chosen and they are not evidence for a factor.
Elsewhere in this documentation the same choice is quoted at 1.83× faster, with the same 3 %
rider and no distinctStates attached to it (Benchmarking,
Project Status, Rationale: Chunk Loading). The three
figures come from the same unrecorded design-time measurement and cannot be reconciled from anything
committed — none of them is a result of the JMH suite, and the run of compressFalcoLevel and
compressMinestomLevel that would settle it has not been done. Until it is, the only part of this
that carries is the direction: level 2 compresses faster than level 6 and stores a little more.
| Property | Minestom | Falco | Why it matters |
|---|---|---|---|
| Work inside the region lock (read) |
readChunkData holds one ReentrantLock across seek, read, decompression and NBT parsing (instance/anvil/RegionFile.java:42, :57-89) |
The read takes no lock at all; only a read that keeps racing a writer falls back to it. Decompression, NBT parsing and palette conversion run in the caller (RegionFile#readRaw, FalcoAnvilLoader#loadChunk) |
This is the whole reason supportsParallelLoading() is worth reporting. With the dominant cost inside the lock, extra threads queue instead of working. |
| Concurrent readers of one region | Serialised by the single lock, plus RandomAccessFile.seek makes shared use unsafe |
FileChannel.read(ByteBuffer, position) does not touch the channel position, so readers of different chunks proceed in parallel (RegionFile#readFully) |
Loading a spawn area touches many chunks of the same region file at once. |
| Chunk lock held while saving | Write lock over the entire serialisation of all sections (instance/anvil/AnvilLoader.java:420-519) |
Read lock only while cloning sections into a snapshot; serialisation and compression happen after it is released (FalcoAnvilLoader#snapshot) |
A write lock blocks readers of that chunk; on saveChunksToStorage this stalls the tick thread for the duration of the serialisation. |
| Header write per chunk save | Rewrites the full 8192-byte header whenever it is dirty (RegionFile.java:182-196) |
Patches the 4-byte location entry and the 4-byte timestamp entry only (RegionFile#writeEntry) |
8192 bytes versus 8 bytes per save. It also narrows the window in which a crash can damage unrelated entries. |
| Palette deduplication on save |
IntArrayList.indexOf(value) per block, i.e. a linear scan for each of the 4096 blocks of a section (instance/anvil/AnvilLoader.java:447), and the same for biomes (:484) |
Hash-based index assignment, one lookup per block (PaletteData#encode) |
Quadratic versus linear in the palette size. Sections with large palettes are the worst case. |
| Re-packing on load |
Palette#load derives bits per entry from the palette length alone (instance/palette/PaletteImpl.java:128-129) |
The stored long[] is validated and handed over unchanged when its bit width matches; only a mismatching file is unpacked and re-applied (FalcoAnvilLoader#apply) |
The common case avoids an unpack/repack round trip entirely. The uncommon case is decoded correctly instead of silently misread. |
| Property | Minestom | Falco | Why it matters |
|---|---|---|---|
Concurrency of saveChunks
|
Interface default starts one virtual thread per chunk (instance/ChunkLoader.java:62-82) |
Chunks are grouped per region, one task per group, bounded by a Semaphore of max(Runtime.availableProcessors(), 2) permits (FalcoAnvilLoader#saveChunks, FalcoAnvilLoader.saveLimit) |
The number of chunk snapshots and compressed byte arrays alive at once is bounded by the permit count instead of by the number of chunks being saved. |
| Uniform sections | Written as a full palette container | Collapsed to a single palette entry with no data array (PaletteData#single) |
A section of pure air or pure stone stores one entry instead of a 4096-entry index array. |
| Repeated array reads | — |
NbtReads copies each array tag once and never calls value() twice |
value() on an array tag copies on every call; a 4096-entry long[] is 32 KiB per copy. |
| Open file handles | Closed when the last chunk of a region unloads, using a reference count that the interface documents as unreliable (instance/ChunkLoader.java:102-108) |
Closed when the last chunk this loader loaded is unloaded, plus a hard cap on open files as a backstop (FalcoAnvilLoader.DEFAULT_OPEN_REGION_LIMIT); never closed under a thread that is still reading or writing, and never opened again after close()
|
Unload calls arrive for foreign chunks, so a count alone either leaks handles or closes files still in use. The cap bounds the worst case regardless. |
| Block state cache |
static CompoundBinaryTag[] sized by Block.statesCount(), populated without synchronisation (instance/anvil/AnvilLoader.java:48, :526-531) |
No global cache; palette entries are built per section (BlockPaletteResolver#toEntry) |
Trades a small amount of repeated work for no shared mutable state and no class-loading-time allocation proportional to the block registry. |
Being explicit about this, because the table above is one-sided by construction:
- On a single thread nothing here is faster, and nothing is measurably slower either. Measured on the region file, Minestom's mean comes out marginally ahead — 1 045 ± 112 against 1 089 ± 48 µs/op — but the intervals overlap, so the row resolves no ordering at all. Everything the design buys is bought under contention. A single-threaded workload that reads one chunk at a time gains nothing here and pays for the version counter, the stricter NBT reads and the length validation; how much it pays is not resolved by any measurement on this page.
- On the save path, four of five measured points resolve nothing and the fifth is confounded by the compression level, as the save table above says. A save-heavy single-threaded server has no measured reason to switch.
- Falco does not parse NBT faster — both use adventure-nbt 5.1.1, and parsing is the largest single cost in the load path.
- Falco does not compress faster — both use
java.util.zip. Falco's default level is lower (ChunkCompression.DEFAULT_LEVELis 2 against Minestom's 6), which is a setting and not an algorithm; either loader could use either level. - Falco writes more data per chunk in one respect: block entities are collected for uniform sections too, which Minestom skips (see the comparison table). That is a correctness fix, not a saving.
- The palette representation is a value record, so a section snapshot allocates. Minestom mutates a palette in place. Falco trades that allocation for the ability to build sections without holding the chunk lock.
Stated plainly, because each of these is a reason to keep using another loader or another tool.
-
No
entities/orpoi/region handling. Only theregion/directory is read and written. Entity and point-of-interest region files of a vanilla world are ignored and are neither migrated nor kept in sync. The MinestomAnvilLoaderdoes not handle them either. -
No DataFixer and no DataVersion migration.
MinecraftServer.DATA_VERSIONis written into every saved chunk (FalcoAnvilLoader.dataVersion, written byFalcoAnvilLoader#snapshot), but the storedDataVersionof a chunk being read is never inspected. Data from an older world version is interpreted with the current schema. Convert worlds with the vanilla client or another tool first. -
No LZ4 (compression type 4) and no custom compression (type 127).
ChunkCompression.fromIdaccepts gzip (1), zlib (2) and uncompressed (3), with the external bit0x80masked off; every other id raisesThe compression scheme <id> is not supported. Only gzip (1), zlib (2) and none (3) can be read(ChunkCompression#fromId). Unsupported compression therefore fails with an explicit error instead of being misread as another scheme. Saving always uses zlib (FalcoAnvilLoader#saveChunk). -
No corruption recovery and no header rebuilding. A header shorter than 8192 bytes, or a
location table with overlapping sector ranges, fails the open
(
RegionFile#readHeader,SectorAllocator#reserve). There is no scan-and-repair mode, no orphaned-sector reclamation and no defragmentation; freed sectors are reused but the file is never shrunk (SectorAllocator#free). -
No
level.dathandling.loadInstanceandsaveInstanceare not overridden, so world metadata (seed, spawn, game rules, world age) is neither read nor written.LastUpdateis written as a constant0(FalcoAnvilLoader#snapshot). -
No preservation of unknown chunk-level tags.
snapshotwrites a fixed key set (FalcoAnvilLoader#snapshot), soHeightmaps,structures,block_ticks,fluid_ticksand everything else present in a vanilla chunk are lost when that chunk is saved. Heightmaps are not restored on load either. Use this loader for worlds the server owns, not as an editor for vanilla worlds you intend to open in the client again. -
No configurable compression level.
compressionLevelis assignedChunkCompression.DEFAULT_LEVELin the constructor and is never read from anywhere else (FalcoAnvilLoader). There is no constructor parameter and no setter for it, so a server that wants level 6 on disk cannot ask this loader for it. The level is a deliberate default and currently a fixed one. -
No loading of partially generated chunks. A chunk whose
Statusis notminecraft:fullis reported as absent —loadChunkreturnsnullafter a throttled warning (FalcoAnvilLoader#isFullyGenerated).InstanceContainerthen generates a replacement (instance/InstanceContainer.java:336-343), which the next save writes over the partial chunk. This is the one place where the loader accepts the overwrite it otherwise exists to prevent, and it does so on purpose: a partially generated chunk has no meaningful contents to preserve, and Minestom'sAnvilLoaderskips it as well (instance/anvil/AnvilLoader.java:133-142). A world mid-generation is still not something to point either loader at. -
No light computation. Stored
SkyLightandBlockLightarrays are read into the section and written back out unchanged (FalcoAnvilLoader.DecodedSection#applyTo,FalcoAnvilLoader#encodeSection). A chunk stored without light stays dark until something else lights it; the Light Engine is that something, and it is a separate module with no dependency in either direction. -
No durability guarantee per save, and no crash consistency. A save writes the payload and the
8-byte header entry through the channel and returns; nothing is forced to the device.
RegionFile.flushischannel.force(false)and it is called at exactly two places — when a region file is evicted or unloaded, and when the loader is closed (RegionFile#flush,FalcoAnvilLoader#closeQuietly,FalcoAnvilLoader#close). A power loss between asaveChunkand that point loses the save, and because the payload and the header entry are two writes with no barrier between them, it can also leave an entry pointing at sectors whose bytes never reached the device. The region lock orders writers against each other; it orders nothing against the page cache. There is no journal, no write-ahead log and no torn-write detection on read beyond the length and sector-range checks. This is not a regression against the built-in loader — Minestom opens its file"rw"and itsRegionFile.close()closes without syncing (instance/anvil/RegionFile.java) — but neither loader is safe against an unclean shutdown, and a server that needs that needs a snapshot on the storage layer. -
No cross-loader coordination. Two
FalcoAnvilLoaderinstances over the same region directory share no sector allocator, no region cache and no lock. The per-entry version counters and the region lock coordinate threads inside one loader, not processes and not two loaders. One loader per dimension directory, and nothing else writing that directory while the server runs.
The governing rule: a chunk that exists on disk but cannot be read must not be reported as absent.
ChunkLoader.loadChunk uses null for "this loader has no data for that chunk". InstanceContainer
reacts by generating a replacement chunk, caching it and firing the load event
(InstanceContainer.java:336-343). That replacement is a normal, dirty chunk, so the next
saveChunk writes it over the bytes that failed to read. A transient IO error, a temporarily
unavailable mount or a parser bug therefore does not merely fail a load in Minestom — it destroys
the data it failed to read, without an error visible to the operator beyond one handled exception.
FalcoAnvilLoader.loadChunk returns null only for the two genuinely-absent cases: no region file
(FalcoAnvilLoader#acquireRegion
returns null without create) and no location entry for the chunk
(RegionFile#readEntry
returns null, handled in
FalcoAnvilLoader#loadChunk).
A chunk that is present but not fully generated also returns null after a throttled warning
(FalcoAnvilLoader#isFullyGenerated),
which matches the intent of the format. Every other failure — malformed header, short read,
unsupported compression, broken NBT, palette index out of range — is logged with context, handed to
the exception manager and rethrown as AnvilChunkException
(FalcoAnvilLoader#failedLoad).
InstanceContainer then completes the load future exceptionally instead of generating
(InstanceContainer.java:367-372), the chunk stays unloaded, and nothing overwrites it.
AnvilChunkException is an unchecked exception so it can cross the ChunkLoader interface, which
declares no checked exceptions
(AnvilChunkException).
saveChunk deliberately does not throw. It logs at error level, increments the error counter
and reports to the exception manager
(FalcoAnvilLoader#saveChunk).
A failed save has already lost the in-memory state either way; propagating would additionally abort
the surrounding save of every other chunk. The one exception is a save on a closed loader: that
is a lifecycle error of the caller rather than a broken chunk, so it propagates as an
IllegalStateException and is not counted as a failed chunk. In saveChunks a task that fails is reported per group
in awaitAll (FalcoAnvilLoader#awaitAll),
and the error count surfaces again in the summary written by close().
Only three classes own a logger:
| Class | Logger | Why |
|---|---|---|
FalcoAnvilLoader |
yes (FalcoAnvilLoader.LOGGER) |
It is the only layer that knows chunk coordinates, region directory and dimension. |
BlockPaletteResolver |
yes (BlockPaletteResolver.LOGGER) |
Reports a substituted block name once; the loader never sees the substitution. |
BiomePaletteResolver |
yes (BiomePaletteResolver.LOGGER) |
Same, for biomes. |
RegionFile, SectorAllocator, BitPacker, ChunkCompression, NbtReads, PaletteData,
SectionCodec, RegionConstants and AnvilDiagnostics deliberately have none. They are leaf
classes that do not know which chunk, region or dimension they are working on, so any line they
logged would be context-free. Instead they throw with the facts they do have — the offending key
and its actual type (NbtReads#missing),
the declared length against the sector allocation
(RegionFile#readEntry),
the long count against the entry count
(PaletteData#read),
the conflicting sector (SectorAllocator#reserve).
The loader catches these and adds the context.
Message schema. Every loader message that concerns a chunk ends with the same trailer, so logs can be grepped and parsed uniformly:
... chunk=[{},{}] region={} dim={}
for example
Failed to load the chunk chunk=[{},{}] region={} dim={}
(FalcoAnvilLoader#failedLoad).
Messages that concern a whole region omit the chunk= part and keep region={} dim={}
(FalcoAnvilLoader(Path, Key, int),
FalcoAnvilLoader#closeQuietly,
FalcoAnvilLoader#close,
FalcoAnvilLoader#acquireRegion,
FalcoAnvilLoader#awaitAll).
Throttling. AnvilDiagnostics decides whether a report is emitted. reportUnknownBlock and
reportUnknownBiome return true only for the first occurrence of a distinct name, and only while
fewer than MAX_TRACKED_NAMES = 64 names are tracked in that category
(AnvilDiagnostics.MAX_TRACKED_NAMES,
AnvilDiagnostics#track).
The cap matters because a broken or heavily modded world can contain an unbounded number of distinct
unknown names, which would otherwise grow the tracking sets indefinitely.
reportPartialChunk(String) throttles the same way but on a different key: it counts partial chunks
in a ConcurrentHashMap<String, LongAdder> keyed by the Status value the chunk carried and returns
true only for the first chunk of each distinct status, again while fewer than MAX_TRACKED_NAMES
statuses are tracked
(AnvilDiagnostics#reportPartialChunk).
A world holding several generation stages therefore names each of them once rather than once in
total, and the counts survive even for a status past the cap. The no-argument overload counts under
AnvilDiagnostics.UNKNOWN_STATUS for a caller that could not read the value
(AnvilDiagnostics.UNKNOWN_STATUS).
Three reports are once-per-loader, each behind its own AtomicBoolean: reportMissingRegionFile,
reportMissingChunkEntry and reportSectionOutOfRange
(AnvilDiagnostics#reportMissingRegionFile,
AnvilDiagnostics#reportMissingChunkEntry,
AnvilDiagnostics#reportSectionOutOfRange).
The loader calls the first two
(FalcoAnvilLoader#loadChunk)
and not the third; a section outside the world is logged at TRACE without going through the
diagnostics
(FalcoAnvilLoader#decodeSections).
The name sets are ConcurrentHashMap.newKeySet(), the status map is a ConcurrentHashMap and the
counters are LongAdder, so reporting from many loader threads is safe
(AnvilDiagnostics()).
Consequence to be aware of: once 64 distinct unknown block names have been seen, further distinct
names are substituted silently. The counters still rise, and unknownBlockCount() saturates at 64.
Close summary. close() writes one line with loaded chunks, skipped chunks, saved chunks,
errors, distinct unknown blocks and distinct unknown biomes, plus the region/dimension trailer. It
is logged at WARN when the error count is greater than zero and at INFO otherwise, so a shutdown
that lost chunks does not read like a clean one
(FalcoAnvilLoader#logSummary).
A second line follows it, and only on a run that skipped something: the breakdown of the skipped
chunks into the three reasons — no region file, no entry in the region file, not fully generated —
with the Status values of the partial chunks and their counts spelled out
(FalcoAnvilLoader#logSkipSummary,
FalcoAnvilLoader#describeStatuses).
It is separate rather than more fields on the line above because the three reasons only matter once
one of them fired, and it is logged at WARN. The distinction is the point of the pair: a loader
that returned nothing has to say which of the three reasons it returned nothing for, and "no region
file at all" and "the file exists and the chunk was never written into it" are two different faults
with two different remedies.
Levels in use, read off the call sites in
FalcoAnvilLoader
and the two resolvers: INFO for the loader opening and for a clean close; WARN for a throttled
data problem, for a close with errors and for the skip breakdown; ERROR for a failed chunk load,
a failed chunk save, a failed group of chunks in saveChunks and a region file that could not be
closed; DEBUG for a region file being opened or closed and for the first chunk found without a
region file or without an entry in one; TRACE for chunk unloads and skipped out-of-world sections.
The two absent-chunk reports sit at DEBUG rather than WARN on purpose — a chunk that was never
written is the normal state of an unexplored world, not a fault.
Thirteen test classes cover the anvil package. The declared column counts @Test and
@ParameterizedTest methods in the sources at commit ca79507 and is checkable by reading them;
the executed column is what JUnit actually ran, and a @ParameterizedTest expands into one
executed test per argument set, which is why the two differ.
| Test class | Declared methods | Executed tests | Needs a Minestom server |
|---|---|---|---|
BitPackerTest |
12 (8 + 4 parameterized) | 34 | no |
PaletteDataTest |
17 (16 + 1 parameterized) | 22 | no |
ChunkCompressionTest |
14 (11 + 3 parameterized) | 21 | no |
FalcoAnvilLoaderIntegrationTest |
25 | unrecorded | yes |
AnvilDiagnosticsTest |
18 | unrecorded | no |
NbtReadsTest |
15 | 15 | no |
RegionFileTest |
14 | 14 | no |
SectionCodecTest |
13 | 13 | no |
SectorAllocatorTest |
9 (8 + 1 parameterized) | 10 | no |
RegionFileConcurrencyTest |
6 | 6 | no |
AnvilDiagnosticsConcurrencyTest |
6 | unrecorded | no |
FalcoAnvilLoaderLifecycleTest |
5 | 5 | yes |
FalcoAnvilLoaderConcurrencyTest |
4 | 4 | yes |
| Total (anvil package) | 158 | — |
The total is the sum of the column above it and nothing else. FileTestBase, the fourteenth file in
falco-anvil/src/test, declares no test method of its own and therefore contributes none.
Layers that need no server. Ten of the thirteen classes import nothing from net.minestom. That
is a direct consequence of the class split: RegionConstants, SectorAllocator, RegionFile,
ChunkCompression, BitPacker, PaletteData, SectionCodec, NbtReads and AnvilDiagnostics
have no dependency on the server or its registries. SectionCodecTest exercises the codec through a
stub PaletteEntryResolver, so palette encoding and decoding are verified without touching the block
or biome registry. RegionFileTest works on real files in a @TempDir.
RegionFileConcurrencyTest and AnvilDiagnosticsConcurrencyTest hammer the same file and the same
diagnostics from several threads and stay server-free for the same reason.
The layer that does. Three classes need one: FalcoAnvilLoaderIntegrationTest,
FalcoAnvilLoaderLifecycleTest and FalcoAnvilLoaderConcurrencyTest. The first is annotated
@ExtendWith(MicrotusExtension.class) (Cyano) and receives an Env parameter, from which it builds
instances with env.createEmptyInstance(loader)
(FalcoAnvilLoaderIntegrationTest).
It needs a real server because the loader touches Chunk, Section, Palette, the block registry
and the biome registry. It covers the chunk round trip through a real region file: absent chunks,
block round trip, region file placement in the dimension directory, NBT on blocks, block properties,
parallel saving and parallel loading. The other two use the same extension: the lifecycle test covers
what a closed loader does with further loads and saves, the concurrency test the loader under several
threads at once, including the open-region limit and the eviction of a region file that is being read.
There is no dedicated unit test for BlockPaletteResolver and BiomePaletteResolver; both are
covered only indirectly through the integration test. BiomePaletteResolver accepts an injectable
Supplier<DynamicRegistry<Biome>> for exactly this purpose
(BiomePaletteResolver(AnvilDiagnostics, Supplier)),
so a server-free test for it is possible and currently missing.
Run everything with:
./gradlew testMinestom sources cited here, at version 2026.06.20-26.1.2:
net/minestom/server/instance/anvil/AnvilLoader.javanet/minestom/server/instance/anvil/RegionFile.javanet/minestom/server/instance/ChunkLoader.javanet/minestom/server/instance/InstanceContainer.javanet/minestom/server/instance/palette/PaletteImpl.javanet/minestom/server/instance/palette/Palette.javanet/minestom/server/utils/validate/Check.javanet/minestom/server/instance/DynamicChunk.java
Format reference: Region file format, Chunk format.
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 — the investigation behind the builders; a record, not a reference
Working on the build