Skip to content

Project Status

TheMeinerLP edited this page Aug 1, 2026 · 2 revisions

Status — Anvil chunk loader and light engine

The complete record of what was built, what was measured, what was measured and then rejected, and what is still open. This is the evidence base the shorter pages summarise: every table below carries the benchmark class and method it came from, its parameters and the configuration of the run that produced it, so that a row can be traced to its source without guessing. It deliberately explains nothing — how the loader and the engine work is Anvil Chunk Loader and Light Engine — and it is written for someone auditing the claims rather than for someone using the library.

This page owns every measured table in the wiki. Where a table appears on more than one page, the copy under Measured is the one that is right, and a correction is made here first and propagated outwards. The same holds for the count of cross-run repeats, which is stated under Against the engine Minestom ships with.

Read this before quoting a number. Everything measured here comes from one machine, on one JVM, under one load, and none of it is an absolute statement about either implementation. What the ± covers is defined once in Rationale: Measurement; how to re-run any of it is in Benchmarking.

./gradlew build is green. The test counts behind that, and the command that reproduces them, are under What is in the branch.

Everything here is experimental and opt-in. Nothing takes effect in a server that does not construct a FalcoAnvilLoader, a FalcoInstance, or reach the light engine either by calling a ChunkLightService itself or by handing an instance the chunk supplier of a ChunkLightScheduler.

Both parts were developed inside Aves, OneLiteFeather's utility library, and moved here once it was clear they are server infrastructure rather than utilities. The history of that development stayed behind; what was learned along the way is written down in this document and in Research.

How to read this page

Four kinds of statement appear here and they are not interchangeable. Each section below holds predominantly one kind, and where a section mixes them the individual sentence says which it is.

Kind Where it lives What it is worth
Measured Measured A JMH number with a named class, method, parameters and run configuration. Bounded by the ± and by the conditions in the provenance line beneath its table.
Structural Facts that cost real effort to establish, Decisions that shape everything else, Defects found and fixed A property of the source that a reader can check by opening a named file, type or member. Not subject to run-to-run variance at all, and therefore the more durable of the two.
Design intent Where things live, the prose under each Measured table What a construct exists for. Never an outcome, and never evidence that the outcome was achieved.
Open or unverified Open, and every TODO(maintainer) comment in the source of this page Either not built, or stated without a source that a reader can reach. Marked rather than dropped, so that the gap is visible.

Contents

The eight measured sections under Measured, in order, with the environment they were taken in first and the decisions they produced last. Seven of the eight carry a table; scaling by world height publishes three claims and no table, and says so at the point where the table would be:

the measurement environment

save stages · region file against Minestom · light engine against Minestom · mixed brightness · scaling by world height · area against per-chunk · replayed change against full · stages of the light path

optimisations these numbers produced


What this is and why

Falco holds three things a Minestom server needs and does not get in this shape from the platform:

  1. An Anvil chunk loader that replaces net.minestom.server.instance.anvil.AnvilLoader. The goal was a loader that is genuinely parallel, that does not silently lose data, and that stays maintainable — developed test-first, on Java 25, using Adventure NBT and JetBrains annotations.
  2. A light engine, because once chunks are loaded, lighting is the next thing a server pays for. Since FalcoLightingChunk it also maintains itself: one call to setChunkSupplier and the light of a world stays current without a caller deciding when to recompute it.
  3. An instance and its chunk, because the one thing InstanceContainer cannot be talked out of is its own lifecycle. No speed gain is claimed and none is measured — chunk and entity ticking lives in the global ThreadDispatcher of the server process, not in the instance. What it buys is an unload path that actually runs for a foreign instance, a generator whose failure cannot publish a half-built chunk, and a load which cannot race an unload into a chunk nothing can reach.

The motivating observation for the loader is structural, not measured. At Minestom 2026.06.20-26.1.2, AnvilLoader reports supportsParallelLoading() == true, but its RegionFile holds one ReentrantLock per region file (instance/anvil/RegionFile.java:42) across the seek, the payload read, the decompression and the NBT parse (:67-92, parse at :88). The parallelism is therefore largely nominal for chunks that share a region file — a claim anyone can check by opening that file at that version, without running anything. Moving the CPU work out of the lock rather than starting more threads is what the three-stage pipeline below exists for; whether it pays is a separate, measured question, answered under Measured.

Environment

Everything in this table is declared in settings.gradle.kts, build.gradle.kts or gradle/wrapper/gradle-wrapper.properties and is checkable there. The Pinned here column matters for anyone reproducing a measurement: a version that is resolved rather than pinned can change under a republication of the BOM without a single commit in this repository.

Pinned here
Java 25 (toolchain and release), no --enable-preview anywhere yes, in the root build.gradle.kts
Minestom 2026.06.20-26.1.2, compileOnly nolibrary("minestom", …).withoutVersion(), resolved through net.onelitefeather:mycelium-bom 1.7.2
Adventure 5.1.1, adventure-nbt used directly — Minestom speaks CompoundBinaryTag natively, so there is no conversion layer yes, adventure-bom 5.1.1
Annotations org.jetbrains:annotations:26.1.0 yes
Tests JUnit 6.1.0, Cyano 0.6.2 (MicrotusExtension) for anything needing a server no — both withoutVersion(), resolved through mycelium-bom 1.7.2
Benchmarks JMH 1.37 via me.champeau.jmh 0.7.3, in the separate falco-benchmarks module yes, both
fastutil 8.5.18 yes
Build Gradle 9.6.1, six modules on one shared version yes

The six modules are falco-anvil, falco-light, falco-instance, falco-benchmarks, falco-demo and falco-bom; the first three are published, falco-bom publishes only their constraints, and the other two are not published at all.

The Minestom version is the one that would invalidate a comparison silently. Every head-to-head table under Measured measures the Minestom of the day the run happened, and that version is decided by mycelium-bom 1.7.2, not by anything in this repository. 2026.06.20-26.1.2 is what it resolved to; a third party reproducing a number has to check that theirs resolves to the same thing before comparing.

adventure-nbt, jetbrains-annotations and fastutil are declared explicitly rather than taken transitively. The first two only reach the classpath through compileOnly(minestom), so any direct use of them would compile by coincidence. fastutil is runtime scope in Minestom's POM and is needed by the light equivalence test and the comparison benchmarks.

Working on this

./gradlew build                                  # compile, javadoc, tests — javadoc failures break the build
./gradlew :falco-anvil:test --tests "*Region*"   # a subset
./gradlew :falco-benchmarks:jmhJar               # build the benchmarks (they never run during build)
java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar ScalingBenchmark -f 1 -wi 2 -i 3

# Compare the two loaders on a world of your own: drop it into falco-demo/world first.
# Run both with the SAME -Pthreads, or the two numbers answer different questions.
./gradlew :falco-demo:runFalcoLoader    -Pthreads=4 -Pchunks=256
./gradlew :falco-demo:runMinestomLoader -Pthreads=4 -Pchunks=256

Two things that will otherwise cost an hour:

  • Do not run two Gradle builds in the same checkout at once. They corrupt build/test-results and surface as EOFException, NoClassDefFoundError or missing jacoco/test.exec — failures that look like real test breakage. rm -rf build and rerun.
  • JMH allows one instance at a time. A crashed run leaves /tmp/jmh.lock behind and every later run fails with "Another JMH instance might be running". Delete the file.

Conventions

Match these; the build enforces some of them.

  • Javadoc on every class and method, with @param / @return / @throws. withJavadocJar() means an incomplete comment fails CI. Class comments explain why, not what, and carry @author / @version / @since.
  • Never write @NotNull. Packages carry @NotNullByDefault in package-info.java; only @Nullable, @Contract and @UnmodifiableView appear explicitly.
  • Test-first, strictly. Every type here was built by writing a failing test, confirming it fails for the right reason, then implementing. Several bugs in this branch were found precisely because a test was written before the code.
  • Tests are package-private, named test<What><Expectation>, use plain JUnit assertions, and avoid @Nested. Anything needing a server uses @ExtendWith(MicrotusExtension.class).
  • Commits follow conventional commits, scoped (anvil), (light), (map).

Releasing and snapshots

All published modules share one version. It lives on a single line of the root build.gradle.kts, Release Please rewrites that line from the conventional commits above, and everything else follows from it.

Push to main Version Endpoint
Merges the Release Please PR the released one, 0.3.0 at ca79507 https://repo.onelitefeather.dev/releases
Anything else the next patch with a suffix, 0.3.1-SNAPSHOT https://repo.onelitefeather.dev/snapshots

The two version strings are the values at ca79507 and are illustrations of the mechanism rather than a statement about what the repositories hold today; the authoritative value is the version = … line of the root build.gradle.kts, which Release Please rewrites.

.github/workflows/release-please.yml holds both publish jobs. They hang off the same release_created output with opposite conditions, so a push publishes a release or a snapshot and never both. A second workflow on push cannot see whether a release was cut and would double-publish.

The snapshot version is derived inside the build rather than passed in, because a -Pversion=… would be overwritten by the version = … assignment that runs after the property is read. -Psnapshot instead makes the root build bump the patch of the released version and append -SNAPSHOT; the publishing block already picks its endpoint from that string. The flag reaches Gradle through the build-task and publish-task inputs of the reusable workflow, which are handed to ./gradlew verbatim — it offers no input for properties or environment. Check the result with ./gradlew -Psnapshot properties | grep '^version:'.

Both endpoints are public, so a consumer of an artefact needs no credentials. Both exist on the Reposilite instance and both have been written to: /releases served 0.2.1 of falco-anvil and falco-light, the two modules published at that time, and /snapshots served 0.2.2-SNAPSHOT, put there by the first push after that release. That was checked once, by hand, at the time; it is not verifiable from this repository and no later check is recorded. falco-instance shipped in 0.3.0 and falco-bom was added after it was cut, so the set of published coordinates has grown since that check.

Facts that cost real effort to establish

Verified against the sources or by running probe code. Knowing these prevents repeating the work.

Minestom's loader interface

  • ChunkLoader#loadChunk is synchronous — it returns @Nullable Chunk, not a future. Parallelism happens because Minestom starts a virtual thread per chunk when supportsParallelLoading() is true. A loader must be thread-safe, not asynchronous.
  • The default saveChunks starts one virtual thread per chunk, unbounded, and its catch branch never deregisters from the Phaser, so one exception hangs it forever. Override it.
  • unloadChunk is documented as arriving for chunks the loader never loaded, which makes reference counting on it unreliable.
  • setChunkLoader does not call loadInstance — only the constructor does. AbstractMapProvider sets the loader afterwards, so level.dat is never read there. Pre-existing, not introduced here.

A behavioural difference between the two Anvil loaders

  • net.minestom.server.instance.anvil.AnvilLoader(Path, Key) resolves only dimensions/<namespace>/<value>/region. It has no fallback to worldRoot/region, so it reads nothing at all from a world in the pre-26.1 layout. The single-argument AnvilLoader(Path) is the only way to reach such a world with it, and it is deprecated for removal. FalcoAnvilLoader#resolveRegionDirectory handles both layouts. Found while building falco-demo, where a comparison that missed this would have reported a fictional speedup.

Two things this project ships that cannot be used together

  • FalcoInstance and FalcoLightingChunk are mutually exclusive. FalcoInstance refuses any chunk which is not a FalcoChunk, because Chunk#onLoad and #unload are package-private and FalcoChunk exists to reopen them. FalcoLightingChunk extends DynamicChunk. Handing a FalcoInstance the supplier of a ChunkLightScheduler therefore fails on the first chunk it loads, not at startup. Either run the self-maintaining light on an InstanceContainer, or run a FalcoInstance and drive ChunkLightService yourself. Found while building the demo servers; the two features were developed separately and never met until then.
  • The way out would be a chunk that is both, which means either FalcoChunk growing the light behaviour or FalcoLightingChunk growing the reopened hooks. Neither is written.

Which client speaks to which server

  • Minestom 2026.06.20-26.1.2 is Minecraft 26.1.2, protocol 775. Read from MinecraftConstants with javap -constants and confirmed against a real server list ping. It is worth writing down because nothing in the dependency coordinate says so, and a mismatched client fails with a message that names neither side.

What can and cannot be replaced

  • Palette is sealed ... permits PaletteImpl. A foreign implementation is a hard compiler error, and Section is a record holding that exact type. Verified with javac and at runtime.
  • Light is not sealed, and Section's canonical constructor is public — a custom light implementation compiles and runs end to end. But Section.clone() calls Light.sky() / Light.block() outright, so a custom implementation is silently replaced on copy.
  • Instance and InstanceContainer are not sealed either, but four instanceof InstanceContainer sites in Minestom make a foreign instance silently take a different path.
  • A fresh LightingChunk reports itself unloaded. It answers super.isLoaded() && doneInit and sets doneInit only in its protected onLoad(). Both ChunkBatch and AbsoluteBlockBatch begin with a check on exactly that and return with a warning about an unloaded chunk, so a batch against such a chunk silently does nothing. This is why FalcoLightingChunk extends DynamicChunk rather than LightingChunk: rebuilding that behaviour would be a defect, not a feature. Measured with a probe in Research: Shared Instances and Batches.

Claims about other light engines

Established by reading the sources of those engines, not by measuring anything. None of them is a performance claim about Falco, and none of them may be quoted as one. Each was taken for a promising lead first and only stopped being one after it was checked; the checks themselves, with the source references behind each, are in Research: Light Engine.

  • The main advantage attributed to Starlight is already here. Falco's BFS pushes a level onto the neighbours of a cell instead of pulling each cell from all six of its own neighbours, which is the difference Spottedleaf names as the reason Starlight beats vanilla. This is a structural property of both implementations, not a measured figure.
  • Starlight has no "extended nibble arrays with a border". SWMRNibbleArray.ARRAY_SIZE is 2048 bytes, identical to vanilla's DataLayer. What does exist is the flat sectionCache / nibbleCache over 5×5 sections — that is a flat byte[] for the column, not a border.
  • Starlight's data-holding gain does not transfer. It comes from vanilla keeping light in a Long2ObjectOpenHashMap<DataLayer> and cloning it per tick. Falco never had that structure.
  • The quoted 12× / 28× / 37× figures do not apply. They compare Minecraft 1.16–1.19 against the old vanilla engine. Spottedleaf withdrew the chunk-generation comparison himself and states for 1.20+ only "Vanilla is still 2x slower, but it is fast enough".
  • Phosphor's optimisations are vanilla-specific bar one. The transferable part is the block-state opacity cache, which is what SectionOpacity already is.
  • There is no scientific literature on this problem. No peer-reviewed work on discrete Minecraft-style flood-fill light propagation exists; the reference text is a blog post (Ben Arnold, Seed of Andromeda). Voxel cone tracing and VXGI solve continuous radiance and do not transfer.

Three assumptions of the approved lighting-chunk design that did not hold

The design in docs/superpowers/specs/2026-07-31-falco-lighting-chunk-design.md was signed off before it was built, and three of its statements turned out to be wrong. The document is left as it was; what replaced it is here.

  • invalidate() does not deliver the light. The spec said the scheduler calls invalidate() on the finished chunks and "Minestom sends an UpdateLightPacket on its own", attaching to the mechanism LightingChunk supposedly already uses. DynamicChunk#invalidate only discards the cached full chunk packet, which reaches whoever receives the chunk next; LightingChunk sends its light explicitly from its own tick. Without a send of our own, a player already standing in the chunk would never see a torch they just placed light up. LightUpdateAware#onLightUpdated and the CachedPacket of UpdateLightPacket in FalcoLightingChunk are what closes that.
  • One block change makes the 3×3 dirty, not the chunk it happened in. The spec had setBlock report only its own chunk while promising seamless chunk borders in the same document; the two contradict each other. A lamp at the eastern edge belongs in the light of the chunk east of it, and that chunk would never be told. A batch test surfaced it. FalcoLightingChunk#reportChanged therefore marks the chunk and its eight neighbours, which is also why the ring of an area is read but never written — a chunk that has to change has to be in the area, not in the ring.
  • The revision counter belongs to the scheduler, not to the chunk. The spec put a counter on each chunk, raised by its own setBlock. With neighbours being marked as well, a counter per chunk has cases in which the recorded value does not move although the chunk was marked again — two counters raised by two different chunks cannot be merged into one number without one. A value that does not move reads as "nothing changed", so a stale result would be accepted and the chunk cleared from the dirty set. ChunkLightScheduler counts marks in its own dirty map instead. This matters more here than almost anywhere else, because writing light also clears the update flag of the section, so a wrong result is never recomputed on its own.

Library traps

  • adventure-nbt 5.1.1: the iterators of LongArrayBinaryTag, IntArrayBinaryTag and ByteArrayBinaryTag skip the last element (index < length - 1). A for-each over packed block data corrupts every chunk. Use size() + get(i). NbtReadsTest documents this as a live check.
  • BinaryTagIO.reader() caps at 131 082 bytes, far too small for chunk NBT. Use unlimitedReader().
  • Every CompoundBinaryTag getter silently returns a default for a missing or mistyped key, which turns a broken region file into an empty chunk. NbtReads exists to make that an error.
  • Block.fromStateId indexes an array without a bounds check and throws for an unknown id instead of returning null.

Test environment

  • MinecraftServer.getExceptionManager() throws before MinecraftServer.init(). Anything resolving a registry in a constructor becomes untestable — this is why the biome resolver is lazy.
  • Cyano's exception handler turns a reported exception into a test failure. Code that reports to the ExceptionManager cannot be asserted on by exception type in tests.

Java 25

  • StructuredTaskScope (JEP 505) and StableValue (JEP 502) are still preview and therefore unusable in a published library — preview class files only run on the exact JDK they were built with, and would force --enable-preview on every consumer. Concurrency here uses Executors.newVirtualThreadPerTaskExecutor(), Semaphore and Phaser.
  • The Vector API (JEP 508) is the same trap. It is the tenth incubator round: without --add-modules jdk.incubator.vector javac already refuses, with it the runtime prints a warning that cannot be suppressed, and the JAR specification has no Add-Modules attribute to carry the flag. Every consumer of the library would have to set a JVM flag, which rules it out regardless of what it might buy.
  • Scoped Values, record patterns, sealed interfaces, FFM and stream gatherers are final and usable.
  • File I/O does not unmount a virtual thread from its carrier (JEP 444), so unbounded virtual threads over file work do not scale — bound them.

Decisions that shape everything else

These were explicit calls, not defaults. Changing one means revisiting the work that followed it. The Why column is a mixture of structural argument and measurement; where a cell rests on a number, the number's source is named in the cell and the table it comes from is under Measured.

Decision Choice Why
Format coverage Core compression plus external .mcc, no LZ4, no corruption recovery Covers real worlds without an extra dependency; Minestom fails hard on oversized chunks, which this does not
Integration Opt-in via ChunkLoaderFactory No breaking change; existing providers behave exactly as before
Own palette Not built — codec-internal representation only Structural: Palette is sealed … permits PaletteImpl. The supporting figure — palette decoding at 4.5 % of the load path, against NBT parse 62 % and inflate 28 % — is from the earlier investigation recorded in Research: Light Engine, not from this benchmark suite, and carries no interval
Own InstanceContainer Not built. An own Instance was, in falco-instance Subclassing the container is closed — its lifecycle hooks are protected members of Minestom's package. Extending Instance makes the barrier visible once, at the chunk. No speed is claimed either way: the tick parallelism lives in the global ThreadDispatcher
Light Light implementation Not built — results handed over via Light#set Avoids the @ApiStatus.Internal calculation methods and the Section.clone() trap
Read failure Throws, never returns null null means "absent", so the server regenerates and overwrites real data on the next save
Compression level 2 (ChunkCompression.DEFAULT_LEVEL), not the platform default 6 Two figures, of different weight. Structural: everything a chunk save compresses runs outside every lock (FalcoAnvilLoader#snapshot releases the chunk read lock before encodeSection), so the level trades file size against wall time and nothing else. Measured: "1.83× faster for ~3 % more bytes" has no run record — see the note under Optimisations these numbers produced
Reader safety in RegionFile Per-entry seqlock A ReadWriteLock would block every reader of a region for the length of a payload write, which is the one thing this loader gains over Minestom's; deferred free brings the contention back through a shared counter and grows the files
Region handle lifetime Usage count, not a retry A retry only narrows the window and has to be repeated at every call site; counting accesses removes the case instead. The cost is that the open-handle cap now bounds the cache rather than the descriptors
Use after close() Throws Ignoring it loses data during shutdown, and waiting is impossible — Minestom owns the load tasks, so there is nothing to wait on

Where things live

falco-anvil/          net.onelitefeather.falco.anvil
                      RegionConstants, SectorAllocator, BitPacker, ChunkCompression,
                      RegionFile, NbtReads, PaletteData, PaletteEntryResolver, SectionCodec,
                      BlockPaletteResolver, BiomePaletteResolver, AnvilDiagnostics,
                      FalcoAnvilLoader, AnvilChunkException

falco-light/          net.onelitefeather.falco.light
                      LightNibbles, BlockFace, BlockLightSource, SectionOpacity,
                      LightPropagator, ChunkLightPropagator, ChunkLightState,
                      ChunkLightService, MinestomBlockLightSource,
                      ChunkArea, ChunkLightArea, ChunkLightScheduler,
                      FalcoLightingChunk, LightUpdateAware

falco-instance/       net.onelitefeather.falco.instance
                      FalcoInstance, FalcoChunk, FalcoInstanceException

falco-benchmarks/     not published. Holds every benchmark, because ScalingBenchmark measures
                      both modules in one run and BenchmarkConstants / SectionStates are used
                      from both sides. LightEngineComparisonBenchmark and
                      LightEngineStageBenchmark live in net.minestom.server.instance.light
                      because the methods they measure are package-private there

falco-demo/           not published. A runnable comparison of the two loaders on a world the user
                      supplies. Rough figures from one machine, not an instrument: falco-benchmarks
                      stays the place where a number becomes evidence.

The three published modules are listed type by type above, and the lists are the Types column of What is in the branch enumerated: 14 for falco-anvil, 14 for falco-light, 3 for falco-instance. Counting the names is the check.

Each module's src/test/java mirrors its own package; *ConcurrencyTest are the stress tests, and LightEngineEquivalenceTest pins the byte identity with Minestom. falco-light depends on falco-anvil in test scope only, for the one case that runs the engine on a chunk that went through the loader. falco-instance depends on neither of the other two.

FalcoLightingChunk sits in falco-light, not in falco-instance, which is worth stating because the opposite looks natural. It needs the light engine and nothing else — it holds no computation of its own, it only reports what changed and when a tick happened. Putting it next to FalcoInstance would make one published module depend on another for a type that has no relationship to an instance implementation. A FalcoInstance does not need a lighting chunk, and the lighting chunk works on any Instance, including the InstanceContainer the server ships with.

Integration with the map provider of Aves happens through its ChunkLoaderFactory, which is a functional interface — neither library depends on the other. The usage side of that is in Anvil Chunk Loader.

Reading order for someone new: RegionFile (the byte container), then FalcoAnvilLoader (the three stages), then SectionOpacity and ChunkLightPropagator for the light side, and ChunkLightScheduler for how that light keeps itself current. FalcoInstance is independent of all of them and can be read on its own.

Charts

Two sets of charts exist and they are not equivalent.

The four committed charts are generated by docs/charts/generate.mjs and are rendered on Benchmarking. The generator is the authority for what each bar holds: the values are literals in that file, so a reader can compare a bar against the table it claims to draw without running anything. The tables they draw are loader-contention (from The region file against the one Minestom ships with), light-engine and light-engine-mixed (from the two light comparison tables) and save-stages (from Where the time goes when saving a chunk).

The generator carries the same rule the tables do: where a table refuses a factor, no bar may print one. loader-contention therefore draws two rows rather than four — the four- and eight-thread rows are left out, because Minestom's half-width exceeds its own mean on both, and the generator says so in a comment next to the rows it omits — and it labels the single-thread bar 1.14× slower (not resolved), so the arithmetic of the two means cannot be read off the bar without the verdict attached to it. The two light charts draw each bar's interval as a whisker, so a pair that resolves nothing cannot look like a pair that does.

The save-stages chart is the one to read against the caveat above it: its four segments are 64, 1 356, 2 701 and 17 µs and its subtitle states the 4 138 µs total, which is the sum this page declines to turn into percentages until full() is published. The generator labels the third segment NBT serialisation and zlib and marks it derived, matching the table rather than the label the row used to carry.

Four further charts were produced from these measurements outside this repository and are not publicly readable, so nothing on this page depends on them. Every number behind them is in the tables below.

Chart Shows
Scaling and comparison 1 to 256 sections, and the head-to-head against Minestom. The head-to-head half predates 69381af and shows the factors from before the opacity table was rewritten
Optimisation Where save time goes, the compression trade-off, the uniform-section fast paths
Vanilla · Minestom · Falco 22 behaviours scored against the format reference
Concurrency defects The five races, their failure rates before the fix, and what the fix costs

A results.json from a JMH run feeds JMH Visualizer directly if you want the same views from a run of your own. No results.json from any run behind this page has been committed, so the charts and the tables are currently the only published form of those runs — see Measurement environment.


What is in the branch

Module Types Tests What it does
falco-anvil 14 13 classes, 178 tests Reads and writes Anvil region files, replacing AnvilLoader
falco-light 14 19 classes, 189 tests Computes block light and sky light for a chunk, and keeps it current on its own
falco-instance 3 5 classes, 36 tests An Instance and its Chunk, with a lifecycle that runs for a foreign instance
falco-demo 17 14 classes, count not reproducible — see below Not published. A runnable comparison of the two loaders on a world the user supplies
falco-benchmarks 25 files Not published. Benchmarks, in their own source set, never run during a build
falco-bom Not published as code. A Maven BOM whose constraints pin the three published modules to one version

The totals, added up here so that nobody has to. The three published modules hold 31 types (14 + 14 + 3) and, with falco-demo, 48; falco-benchmarks is not added to that, because its cell counts files in a separate source set rather than types. Test classes add to 51 across the four modules with tests (13 + 19 + 5 + 14). Executed tests add to 403 for the three published modules (178 + 189 + 36). No suite-wide test total is stated, because the falco-demo cell is not reproducible at ca79507 — the three columns that are reproducible are the only ones that go into a sum.

What was re-checked at ca79507 and what was not. The Types column, the files count and the test-class counts were re-derived from the tree at that commit and match the table exactly. The executed-test counts were not: they come from a build whose commit was not recorded, and what the tree declares at ca79507 is 149 @Test plus 9 @ParameterizedTest members in falco-anvil, 187 + 1 in falco-light, 36 + 0 in falco-instance and 139 + 0 in falco-demo. The first three are consistent with 178 / 189 / 36 once the parameterised methods expand; the fourth is not consistent with anything published, which is why that cell says so instead of carrying a number.

How to reproduce these counts. The Types and files columns are find <module>/src/main -name '*.java' ! -name 'package-info.java' | wc -l, counted at ca79507 — a working tree with uncommitted files will differ — and for falco-benchmarks the figure counts every file of src/jmh/java, the three package-info.java included. The Tests column is a test-class count and an executed-test count: ./gradlew test, then count the testcase elements of <module>/build/test-results/test/*.xml. A @ParameterizedTest contributes one class member and several executed tests, so the two numbers do not have to agree. Counting the test files instead gives 14 for falco-anvil, not 13: FileTestBase is a shared fixture holding no test method, and the class count is of classes that produce test results.

ChunkLoaderFactory, the opt-in seam, is not counted here: it lives in Aves rather than in this repository.

Anvil loader

Three stages, so the expensive work never happens under a lock: the chunk state is copied under its read lock, the conversion to compressed bytes runs lock-free, and only the transfer into the region file is guarded. saveChunks is grouped per region and bounded by a semaphore rather than starting one virtual thread per chunk.

Region files use positional FileChannel operations, so reads of different chunks proceed in parallel, and a per-entry seqlock keeps a reader from being handed a sector that was recycled while it read. Every access registers itself on the handle; a file leaves the cache when the last chunk this loader read from it is unloaded, and is closed by whichever thread finishes with it last. The cap on open handles is a backstop on the cache, not on descriptors.

Light engine

Block light and sky light, across section borders, across chunk borders, and incrementally after a single block changed. The algorithm has no Minestom dependency — the registry sits behind BlockLightSource, the same separation the Anvil codec uses for palettes — and results are handed to a chunk through Light#set, which is the stable part of that interface rather than its internal calculation methods.

One ChunkLightService serves any number of threads, because it keeps no state between calls. That is a property worth stating rather than assuming: the working buffers live in a propagator built per call, and handing several threads one propagator is what made the engine produce silently wrong light before.

A chunk that keeps its own light up to date

Using the engine was manual until now: a caller had to notice that a chunk changed, decide when to recompute and call the service. FalcoLightingChunk gives Falco the entry point Minestom sets with setChunkSupplier(LightingChunk::new), without giving up what the engine gained — the computation still runs in a service that is thread-safe per call and still hands its result over through Light#set, so it is not welded to one chunk implementation. Minestom's own engine is the more restricted of the two: it computes light for LightingChunk and for nothing else.

ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService());
instance.setChunkSupplier(scheduler.supplier());

Five types, and only one of them holds behaviour:

Type Responsibility
FalcoLightingChunk Three overrides, each of which only reports something. setBlock reports the changed position, onLoad reports a change of unknown extent, tick triggers the pass, onLightUpdated sends.
ChunkLightScheduler The dirty set, the once-per-tick trigger, area forming, the executor, back pressure and the staleness rule. All the complexity, at one address.
ChunkArea A chunk coordinate pair, and the flood fill that cuts a dirty set into capped connected groups. Pure arithmetic, no Minestom, so both its rules are testable without a server.
ChunkLightArea Computes one group in a single pass: reads its chunks plus one ring, exchanges borders until settled, writes back the group and never the ring. Keeps the light of a chunk on its own, bounded and least-recently-used, so the next pass replays the reported positions on it instead of searching the chunk again.
LightUpdateAware The hook Minestom does not have, so the scheduler can deliver a result without knowing which chunk type it is talking to.

Five properties of the cycle that are decisions rather than details:

  • Once per tick, not once per chunk. Chunk#tick(long) runs per chunk, but a pass has to see every change of the tick before it forms its areas. The tick timestamp is the same value for every chunk of one tick, so the scheduler runs its pass for the first chunk reporting a new one. If no chunk ticks, nothing happens — which is correct, because then nobody is looking.
  • A change marks the 3×3, and the ring of an area is read but never written. Those two are the same rule seen from both ends: a chunk whose light has to change must be in an area, because a ring chunk has only seen the light of the area and never what lies on its own far side, so writing it back would darken it. This was designed out here from the start and has since been carried back into calculateWithNeighbours, which now writes only the chunk in the middle of its 3×3 — see Defects found and fixed.
  • A changed block costs a changed block. The position of every change is handed to the area together with the mark, and the area replays it on the light it already holds for that chunk rather than searching the chunk again. The eight neighbours are marked as well, because light crosses borders, but nothing of theirs is thrown away: their own blocks did not move, and what reaches them across the border is derived again by every pass anyway. A change that cannot be placed — a chunk generated, loaded, or written past setBlock — is reported as unknown and lit from the block states, which is what every change did before. The measured effect is under Measured.
  • An area has an upper bound, and the bound is not optional. One ChunkLightState is roughly 100 KB at the height of an overworld chunk, so sixteen chunks plus their ring is a few megabytes of working memory per pass — and every chunk of an area is also read and turned into opacity tables inside one tick, which is the cost that actually grows with the area. The flood fill stops at maxAreaSize (16 by default) and the remainder starts the next area; the seam between two parts settles on the following tick, because each part reads the other as its ring.
  • Nothing ever blocks on a computation. A chunk hands out whatever its sections hold right now, which is the previous result while a new one is in flight.
  • One scheduler serves exactly one instance. The dirty set is keyed by chunk coordinates alone and every instance ticks with the same timestamp, so a shared scheduler would light the wrong chunks and run its pass for only one instance per tick. A second instance is refused with an IllegalStateException rather than left to fail as a dark world.

The executor is a constructor argument. The default gives every area its own virtual thread and bounds them with a semaphore at the processor count — the same shape FalcoAnvilLoader uses for its saves, with the bound taken inside the task rather than around the submission so the chunk that happened to trigger the pass is not the one that waits. Handing in Runnable::run instead makes the whole cycle synchronous, which is what lets the tests of this path assert on a result after calling tick rather than waiting for one. A server that already runs a pool can hand that over.

How the finished light reaches a client is where this deviates from the design it was built to; see Three assumptions of the approved lighting-chunk design that did not hold above.

Instance

FalcoInstance extends Instance directly rather than InstanceContainer: the chunk lifecycle hooks a subclass would have to override are protected members of net.minestom.server.instance, so a subclass outside that package cannot reach them. Starting from Instance makes the barrier visible once, at the chunk, where FalcoChunk answers it. The reason the module exists is InstanceManager#unregisterInstance, which unloads chunks only for an InstanceContainer and leaves every chunk, tick partition and entity of any other instance behind.

The generator writes into clones. applyGenerator hands the generator copies of the palettes of every section and moves them over only after it returns; InstanceContainer#generateChunk hands over the live ones and catches whatever the generator throws into the exception manager. A generator that fails halfway there leaves a chunk that is half built, published and reported as loaded, while the caller who asked for the chunk is told nothing. Here the failure travels to that caller and the chunk is exactly as it was. The cost is one palette clone per section, and on the case that matters — a chunk that is still empty, which is where a generator normally runs — a palette is in single-value mode and holds no array at all, so the clone is a few bytes. Holding the write lock for the commit only, rather than across the whole generator, is a side benefit and not the reason.

loadingChunks is also the lock of a chunk position. Starting a load, publishing its result and unloading the chunk all happen inside a ConcurrentHashMap#compute on the index of that position, so the three serialise without a monitor over the whole instance. The unload wins and the load throws its own result away: waiting would block an unload on a disk read for a chunk nobody wants, and aborting is impossible because the loading thread is already inside foreign code. Minestom lets the two overlap, and the chunk that loses that race stays in the world with its loaded flag already cleared and nothing left that could ever unload it.


Measured

Every table in this section carries a provenance line naming the benchmark class and methods, the parameter values, the run configuration and what the ± covers. Reproduce any of them with ./gradlew :falco-benchmarks:jmhJar and the class name — except for the parts of Where the time goes in the light path that are marked as coming from a standalone rebuild, which is not JMH and cannot be re-run from this repository.

Measurement environment

CPU TBD — model, physical cores, SMT on or off. Recorded only as "a 16 core machine", which does not say whether that is 16 physical cores or 8 with SMT
Frequency policy TBD — turbo on or off, governor
RAM TBD
Storage and file system TBD — the loader tables all touch it
OS and kernel TBD
JVM TBD — vendor, full version string, garbage collector. The toolchain is Java 25; the JVM that ran the benchmarks is not recorded anywhere
Machine state not idle
Git commit per table TBD for five of the eight. Recorded only for the two light comparison tables and the light stage table: 69381af and its predecessor for the UNIFORM and stage tables, 0e8fbb5 onwards for MIXED
Run date per table TBD — recorded for none of the eight
Raw results.json not committed for any published table

What follows from the empty rows. Absolute microseconds below are a property of that unrecorded machine and nothing else. Ratios between two methods measured in the same run are the durable part, which is why the tables are read as ratios and why the two repeated runs — the nine-chunk re-run under Lighting an area against one neighbourhood per chunk and the earlier run under A replayed block change against lighting the chunks again — are the most useful evidence on this page: ratios reproduce, third digits do not. Background load on a machine that was not idle is also a live candidate explanation for the widest error bars here, which are all in the loader contention table.

One heap gap worth naming. Every benchmark class pins -Xms equal to -Xmx in jvmArgsAppend except AreaVsPerChunkBenchmark and IncrementalVsFullBenchmark, which set no heap flags at all. Those two are also the two that build a world of real Minestom chunks, so they have the largest live set and the most garbage-collector sensitivity in the suite. Their numbers rest on a default, unrecorded heap configuration.

Where the time goes when saving a chunk

Stage Time Lock held Kind
Snapshot 64 µs chunk read lock measured (snapshot)
Codec, without compression 1 356 µs none measured (codecWithoutCompression)
NBT serialisation and zlib compression 2 701 µs none derivedcodeccodecWithoutCompression
Transfer 17 µs region lock measured (transfer)

ChunkSaveStageBenchmark.snapshot / .codec / .codecWithoutCompression / .transfer, distinctStates = 200, 24 sections (BenchmarkConstants.OVERWORLD_SECTIONS), one thread, one fork, 5 warmup and 5 measurement iterations of 1 s per the class annotations — no command line was recorded for this run, -Xms1g -Xmx1g, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. The run printed no ± for these rows and none was recorded, so no significance test can be applied to them and no ratio between them is defensible. One fork: an interval, had one been recorded, would cover variance between iterations of one JVM and not between JVM launches — see Rationale: Measurement.

Half the class is unpublished here too. ChunkSaveStageBenchmark declares @Param({"8", "200"}) and five methods, which is ten configurations; the four rows above are the 200 level of four of them. The 8 level is not published, and neither is full() — the fifth method, which exists so the sum of the stages can be checked against a whole save.

The third row is a difference of two benchmarks, and it does not measure what its old label said. codec() is compress(serialize(encode(…))) while codecWithoutCompression() is encode(…) alone — it returns a CompoundBinaryTag and never serialises it. The difference therefore contains the NBT serialisation and the zlib pass, not zlib alone. The value 2 701 µs is correct for what it actually measures; only the label was wrong.

The structural claim is the strong one here, and it needs no benchmark at all. FalcoAnvilLoader holds the chunk read lock only across the Section.clone() loop and the block-entity collection (FalcoAnvilLoader#snapshot), releases it, and encodes and compresses on the clones; the region lock is taken by RegionFile#writeRaw for the transfer alone. Anyone can verify that by reading the file. The table's job is to say how much of the work sits in the lock-free part, and on these four rows that is 1 356 + 2 701 against 64 + 17.

Two percentages that used to stand here have been withdrawn pending their arithmetic, and ChunkSaveStageBenchmark.full — the method that exists to check the stages against the whole save — has never been published. Publishing it would let a reader verify the decomposition instead of taking it. Until then, what this table supports is an ordering rather than a share: the two lock-free stages are the large ones and the two locked stages are the small ones — 1 356 and 2 701 µs against 64 and 17 — and compression-plus-serialisation is the larger of the two lock-free stages. No ratio between these rows is quoted, because none of them carries an interval. That ordering is what made compression the optimisation target.

The region file against the one Minestom ships with

Reading and writing one chunk, µs/op. The read column carries a verdict rather than a factor, because two of its four rows do not support one.

Threads Falco read Minestom read Read verdict 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 in this run — not reproduced 2 637 ± 204 2 643 ± 153
4 1 378 ± 84 11 021 ± 16 470 no factor admissible 2 672 ± 43 2 678 ± 115
8 2 438 ± 252 530 905 ± 1 928 261 no factor admissible 3 098 ± 658 3 102 ± 245

RegionFileComparisonBenchmark.falcoRead / .minestomRead / .falcoWrite / .minestomWrite, distinctStates = 200, one thread count per row passed as -t (JMH takes one per run, so this is four runs, not one), one fork, 3 warmup and 5 measurement iterations, -Xms1g -Xmx1g, @State(Scope.Benchmark) with a nested per-thread slot, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. Iteration time: the class annotates time = 1 for both warmup and measurement. Earlier documentation of this table said 2 s, and no -r appears on the command line that was recorded, so the run's actual iteration time is unrecorded; the annotation says 1 s. ChunkSaveComparisonBenchmark is the class that really uses 2 s iterations. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

Read the four read rows against their intervals rather than against their means:

  • One thread: no difference is resolvable. Falco's interval is [1 080, 1 326] and Minestom's is [1 005, 1 115]; they overlap between 1 080 and 1 115. The means put Falco 14 % behind, but at this precision that gap is not resolved, and the row must not be quoted as "Falco is 1.14× slower" — nor as a tie. This is the row where Falco looks worse, and it gets the same treatment as the rows where it looks better. What the row does support is that no advantage is visible without contention, which is exactly what the design predicts: the claim was always about lock granularity, and with one thread there is no lock to contend for.
  • Two threads: Falco is faster in this run, and this row does not reproduce. [1 150, 1 212] against [1 755, 2 645] are disjoint, so the difference holds at this precision. Conservative bounds on the ratio are 1.45× to 2.30×; the wider of the two relative half-widths is 20 %, which permits one decimal, hence 1.9× faster rather than the 1.86× the means give. The second run of this table does not reproduce it. An independent run of the identical configuration, published in Anvil Chunk Loader, measured Minestom at 103 437 ± 856 306 µs/op at two threads — 47 times the mean printed above, and a half-width 8.3 times its own mean, which under the rules used here carries no factor at all. Falco's two-thread figure did reproduce, at 1 174 ± 71. The 1.9× is a property of this run and of nothing else; what survives both runs is the direction and the loss of predictability. The whole of what the two runs agree and disagree on is set out under the write rows below.
  • Four threads: the direction holds, no factor does. Minestom's half-width is 16 470 on a mean of 11 021 — 149 % of the mean, putting the lower end of its interval at −5 449. A measurement whose interval contains negative time does not constrain a ratio at any precision. This is the same test that disqualifies the eight-thread row, applied to the row above it.
  • Eight threads: the same, more so. The half-width is 363 % of the mean.

The four- and eight-thread rows are not a null result; they are the interesting one. What they show is that Minestom's read time stops being predictable under contention — it scatters over three orders of magnitude at eight threads — while Falco's half-width stays at or below a tenth of its own mean at every thread count in this table, 10.3 % at its widest. For a server, an unpredictable read is the worse failure of the two, and dispersion is a claim these rows genuinely support where a factor is not.

Writing shows no resolvable difference at any thread count. All four write pairs overlap, which is not the same as the two being equal and is not evidence that they are. The mechanism is what makes a tie the expected outcome: both implementations take a lock to allocate sectors and update the header, so the write path has no lock granularity to win on. Claiming otherwise would be easy and wrong.

This table was run a second time, and the two runs part company on Minestom's side only. The repeat is published in Anvil Chunk Loader at the same settings — same class, same methods, distinctStates = 200, one -t per row, one fork. Falco reproduces at every thread count: 1 089 ± 48, 1 174 ± 71, 1 370 ± 200 and 2 282 ± 248 against the four figures above, every pair of intervals overlapping. Minestom reproduces at one thread only, 1 045 ± 112 against 1 060 ± 55. Above it, the repeat gives 103 437 ± 856 306, 302 704 ± 674 429 and 297 075 ± 593 563 where this table has 2 200 ± 445, 11 021 ± 16 470 and 530 905 ± 1 928 261 — a mean 47 times the published one at two threads, and on every one of the three a half-width larger than its own mean. That asymmetry is the more durable result, and it is worth more than the factor it costs us. Falco's read time is reproducible across JVM launches and Minestom's is not, above one reader — which is the same statement the half-widths make within either run, now made across two of them. What neither run establishes is a magnitude.

A third run of the four-thread row exists at two forks, and it is the only published Falco number whose ± covers more than one JVM launch. Recorded in Anvil Chunk Loader: at four threads, two forks and ten measurement iterations, Falco 1 325.6 ± 21.1 µs/op against Minestom 359 690.8 ± 97 498.3 µs/op. It belongs next to the four-thread row above, and it says three things about it. Falco's figure reproduces — [1 304.5, 1 346.7] against this table's [1 294, 1 462], overlapping — across a configuration that samples JVM launches rather than only iterations, which is the objection every other table on this page is exposed to. Minestom's does not: 359 690.8 against 11 021 is a factor of 33 on disjoint intervals, from the same class at the same thread count. And the two-fork run is the one row in the project where Minestom's read is bounded well enough to divide by at all — its half-width is 27 % of its mean rather than 149 % — which puts the two sides conservatively between roughly 190 and 350 times apart in that run, the bound Anvil Chunk Loader states for it and no more precisely than that. That is a statement about one pair of JVM processes at one thread count and not a property of either implementation, because the operand it rests on moved by a factor of 33 between two runs of the same configuration. It is quoted here because it is the strongest single piece of evidence for the direction, not because it fixes a magnitude.

Two-fork control: RegionFileComparisonBenchmark.falcoRead / .minestomRead, distinctStates = 200, four threads (-t 4), two forks, ten measurement iterations — an override of the class annotations, which are one fork, 3 warmup and 5 measurement iterations of 1 s. The warmup count and iteration time of this run are not recorded, and neither is a full command line. -Xms1g -Xmx1g, JMH 1.37, same 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. At two forks and ten iterations the ± pools iterations across both JVM launches, so unlike every other interval on this page it samples run-to-run JVM variance — see Rationale: Measurement. The conservative ratio bounds are (b − eb) / (a + ea) to (b + eb) / (a − ea).

The comparison is fair by construction, not by assertion. RegionFileComparisonBenchmark#setUp produces the payload once with the Adventure writer both implementations use and hands the same bytes to both region files, so no part of this result is a compression level in disguise. That is a fact about the setup method, checkable without running it.

Only one of the two distinctStates levels is published. The class declares @Param({"8", "200"}), so a full run produces a second set of four read rows and four write rows at 8 states — the cheaper payload, and therefore the case in which the byte transfer weighs more against the inflate and the parse. Those eight rows are not in this document.

Scaling of the series, which no single row shows: from one thread to eight, Falco's read mean grows from 1 203 to 2 438 µs and Minestom's from 1 060 to 530 905 µs. Both are read across four separate runs, so the comparison between thread counts is between runs rather than within one, and the growth ratios inherit that.

Against the engine Minestom ships with

Before any timing is quoted: both engines produce the same bytes, and that is enforced twice. LightEngineEquivalenceTest runs the full cross product of LIGHT_SOURCES = {0, 1, 2, 4, 8, 16, 64, 128, 512} and OCCLUSION_PERCENTS = {0, 10, 30, 50, 70, 90} — 9 × 6 = 54 scenarios — on every build and asserts the 2048 bytes of each side are equal, plus a check that a section holding sources did not simply stay dark, which would make the comparison vacuous. LightEngineComparisonBenchmark.verifyBothEnginesAgree repeats the comparison in @Setup for every trial and every parameter combination and throws before a single measurement is taken if the arrays differ. Both are facts about the code, not measurements, and both are checkable by opening the two files. They close the most common way a benchmark lies — winning time by computing something else.

The measured side of the comparison is LightPropagator against the real BlockLight.buildInternalQueue and LightCompute.compute, reached by placing the benchmark class in net.minestom.server.instance.light because those two methods are package-private there. It is not a reimplementation of Minestom's engine.

Measured before and after 69381af in one session on the same machine, with a Minestom column as the control. The After column is the load-bearing one; the Before ratios do not divide out of the two columns beside them, which is worked through below the table. µs/op:

Sources Solid Falco before Falco after Minestom Before After
1 0 % 74.2 ± 1.9 44.5 ± 0.6 49.4 ± 1.3 1.42× slower 1.11× faster
1 30 % 61.8 ± 1.6 39.3 ± 0.8 62.0 ± 2.0 1.03× slower 1.58× faster
8 0 % 137.7 ± 7.4 98.3 ± 2.4 121.1 ± 5.5 1.18× slower 1.23× faster
8 30 % 144.7 ± 1.8 119.3 ± 3.5 204.2 ± 3.7 1.37× faster 1.71× faster
64 0 % 135.9 ± 2.8 109.2 ± 1.6 126.5 ± 5.6 1.08× slower 1.16× faster
64 30 % 152.7 ± 1.8 122.6 ± 1.3 206.6 ± 4.2 1.37× faster 1.68× faster

LightEngineComparisonBenchmark.falco / .minestom, one section, emissionMix = UNIFORM (every source a glowstone block), lightSources and occlusionPercent as in the first two columns, one thread, run as -f 1 -wi 5 -i 10 — an override of the class annotations, which are 3 warmup and 5 measurement iterations of 1 s — -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, commits 69381af and its predecessor, run date not recorded, no results.json committed. Ten measurement iterations put the t multiplier at 4.78 rather than the 8.61 of a five-iteration run, so these intervals are the tightest on this page for a given dispersion. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

All six After rows are supported, and they should be read without hedging. Each Falco interval is disjoint from the corresponding Minestom interval — the closest pair is 1 source at 0 % solid, [43.9, 45.1] against [48.1, 50.7], which still do not touch. Taking the conservative bounds (b − eb) / (a + ea) to (b + eb) / (a − ea) on each row gives 1.07×–1.16×, 1.50×–1.66×, 1.15×–1.32×, 1.63×–1.80×, 1.09×–1.23× and 1.63×–1.74×. The headline "1.11× to 1.71× faster" is inside those bounds on every row. Every one of the six is inside the two-decimal band the rule in Rationale: Measurement allows — the wider relative half-width of every one of the six pairs is under 5 %, and the rule permits two decimals up to 10 % — so the printed factors stand as they are. The bounds are still the better form of each, and they are why the bounds column exists.

One row of the Before column does not support its factor, and it is worth saying so rather than letting it stand because it flatters the After column by contrast. At 1 source and 30 % solid the before figure is [60.2, 63.4] against Minestom's [60.0, 64.0] — overlapping, so 1.03× slower is not a resolved difference and the honest reading of that cell is "level". The other five Before rows are disjoint against the published Minestom column, so their direction is resolved.

Their magnitudes are not reproducible from the columns printed beside them, and a reader checking the table will find that in a minute. Divide Falco before by Minestom and not one of the six Before cells comes back: 74.2 / 49.4 is 1.50, not 1.42; 137.7 / 121.1 is 1.14, not 1.18; 204.2 / 144.7 is 1.41, not 1.37; 206.6 / 152.7 is 1.35, not 1.37; 135.9 / 126.5 is 1.07, not 1.08; and 61.8 against 62.0 puts Falco marginally ahead rather than 1.03× behind, so that cell's direction does not follow either. Five of the six miss by more than a rounding step. The After column reproduces exactly on all six rows, which is what identifies the published Minestom column as the one the After run was measured against. The likeliest explanation for the Before column is that its ratios were taken against a Minestom measurement from the earlier run that was never published — but that is a reading, not a record, and it is marked below rather than assumed. Until it is settled, the Before column carries a direction and an order of magnitude and nothing finer; the After column, whose arithmetic checks out against the two columns beside it, is the load-bearing one.

Minestom is the natural control for this pair of runs, because its code did not change across 69381af — that is what makes a before-and-after comparable at all. What is published is one Minestom column, and the After ratios were computed against it. A reader should in any case treat the Before and After Falco columns as two runs rather than one: they are separated by a rebuild, which means a fresh JVM, a fresh JIT plan and a fresh set of ergonomic decisions on each side.

Falco is ahead in all six scenarios after the change, against two before it — counting the Before rows whose intervals are disjoint from Minestom's in Falco's favour, which is the two 30 % solid rows, and not counting 1 source at 30 % where the two overlap. The lead on solid blocks grew rather than being traded for the empty rows. This is a result under the conditions in the provenance line — one section, one machine that was not idle, sources of equal brightness, one JVM launch — and not a general statement about either engine.

No repeat run of this table exists, and this page is the register of the ones that do. Four cross-run repeats exist in this project: the nine-chunk AreaVsPerChunkBenchmark re-run at Lighting an area against one neighbourhood per chunk, the earlier IncrementalVsFullBenchmark run at A replayed block change against lighting the chunks again, the second single-fork run of the whole loader table, and the two-fork four-thread RegionFileComparisonBenchmark control — the last two both recorded in Anvil Chunk Loader and discussed at The region file against the one Minestom ships with. None of the four is a repeat of this table. The Before/After pair above is a repeat of a different kind — the same benchmark across a code change, with Minestom as the control — which tests whether the change did what it claims and not whether the numbers are stable across JVM launches. Where another page counts these differently, this section is the one that is right.

The occlusion axis is a benchmark parameter, not a survey of real worlds. The lead grows with occlusionPercent: at one source it goes from 1.11× at 0 % solid to 1.58× at 30 %. The reason for measuring at 30 % is that a solid block ends a search early, so an entirely open section is the upper bound of the work rather than the typical case. Nothing here establishes what share of solid blocks a real chunk holds.

The earlier reading of the pattern was wrong and is worth recording. It said an empty section favours Minestom because our opacity table is built unconditionally, and a section with solid blocks favours us because the table is then read many times. The table is still built unconditionally; it now costs a quarter. The stage breakdown below also shows that Falco's search was already the faster of the two before the change — 33.85 µs against a figure for Minestom's search of 53.9 µs which is derived, not measured directly, because Minestom's search consumes the queue it is handed and cannot be run on a prepared one. The entire deficit came from the preparation, never from the algorithm.

With sources of mixed brightness

This is the honest case, and it is here because it is the one where Falco's lead shrinks.

LightEngineComparisonBenchmark gained an emissionMix parameter in 0e8fbb5. MIXED cycles the sources through glowstone, lantern, torch, redstone torch and magma block, which the registry gives 15, 15, 14, 7 and 3; positions are drawn identically to UNIFORM, so the levels are the only difference. µs/op:

Sources Solid Falco Minestom Verdict
8 0 % 118.97 ± 8.89 126.54 ± 9.55 intervals overlap
8 30 % 116.50 ± 7.63 201.46 ± 16.83 1.7× faster, bounds 1.49×–2.01×
64 0 % 150.42 ± 32.73 162.20 ± 3.11 intervals overlap
64 30 % 149.42 ± 12.64 252.26 ± 9.28 1.7× faster, bounds 1.50×–1.91×

LightEngineComparisonBenchmark.falco / .minestom, one section, emissionMix = MIXED, lightSources and occlusionPercent as in the first two columns, one thread, one fork, -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, from 0e8fbb5 onwards, run date not recorded, no results.json committed. No command line was recorded for this run. The class annotates 3 warmup and 5 measurement iterations of 1 s, and its javadoc recommends -p emissionMix=MIXED -p lightSources=8,64 -f 1 -wi 3 -i 5 for exactly these rows — which is five measurement iterations rather than the ten the UNIFORM table above was run at, and would put the t multiplier at 8.61 instead of 4.78. The half-widths here are wider than the UNIFORM ones on every comparable row, which is consistent with that but does not establish it. The lightSources = 1 level of this parameter combination is not published, although the class declares it — the class javadoc recommends running MIXED with -p lightSources=8,64, which is what these four rows are. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

The two open-sky rows resolve no difference. At 8 sources, [110.1, 127.9] against [117.0, 136.1] overlap; at 64 sources, [117.7, 183.2] against [159.1, 165.3] overlap, and Falco's own half-width there is 22 % of its mean, the widest in either light table. Neither row is evidence that the two engines are equal — it is the absence of a resolved difference, not a measured tie — and neither may be quoted as a factor in either direction.

The two 30 % rows hold and are the same finding as in the UNIFORM table. The lead where solid blocks are present survives mixed brightness essentially intact; what mixed brightness takes away is the lead in the open-sky rows.

A position is queued more than once when sources of different brightness reach it, which is exactly the case a bucket queue is for; the standalone rebuild put a bucket queue 32–36 % faster on that case, which is a direction rather than a factor. The benchmark can now see it, which is what the decision under Investigated and deliberately not built was waiting for.

The cross-table comparison that used to stand here has been withdrawn because one of its two operands cannot be traced to a published table. What can be said from the tables as they stand: at 64 sources and 0 % solid, UNIFORM measures Falco at 109.2 ± 1.6 and MIXED at 150.42 ± 32.73. Those are two separate runs with different half-widths and the second one is wide, so the difference between them is a direction, not a percentage.

Scaling by world height

  • Block light is linear across the whole range — 33 µs per section at 1 section and at 256.
  • Sky light is not. Cost per section rises from 84 µs to 104 µs past roughly 64 sections.
  • A least-squares fit over the vanilla range (≤ 24 sections) predicts 21 423 µs of sky light at 256 sections. The measured value is 26 597 µs — the forecast understates it by 19.5 %. For block light the same method lands within 1.8 %.

ScalingBenchmark.blockLightBySectionCount / .skyLightBySectionCount, sectionCount ∈ {1, 2, 4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 128, 192, 256}, distinctStates not read by either method, one thread, one fork, 3 warmup and 3 measurement iterations of 1 s per the class annotations — no command line was recorded for this run — -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. No table is published and no ± is recorded for any of these figures, so no significance rule applies to them. Three measurement iterations is the smallest sample in the suite: at two degrees of freedom the 99.9 % t multiplier is 31.6, more than three times the 8.61 of a five-iteration run, so an interval here would have been wide and a narrow one would have meant only that three iterations came out alike. One fork, as everywhere on this page — see Rationale: Measurement.

What each of the three claims rests on, separately. "Block light is linear" cites the two endpoints of a fifteen-point axis; linearity is a statement about the thirteen points between them, and those are not shown. The extrapolation figures are derived: they come from a fit whose residuals, coefficients and goodness of fit are not published, so 19.5 % and 1.8 % have no quantified uncertainty of their own and should be read as one significant figure. The arithmetic behind 19.5 % is (26 597 − 21 423) / 26 597, which is reproducible from the two numbers given.

The direction is the durable part, and it does not depend on the fit. ChunkLightPropagator#seedSky walks every column from the top down and queues one entry per cell until the first block that stops the light, so the seeding cost grows with the volume of open sky while a block-light search grows only with what its sources reach. That is a property of the code, readable in that method, and it is the mechanism the non-linearity is evidence for rather than something the fit had to establish.

Measuring the exotic sizes rather than extrapolating from common ones is the only reason the non-linearity is visible at all: a fifteen-point axis reaching 256 sections is what makes the bend past 64 sections observable, and a suite that stopped at the vanilla 24 would have reported a clean straight line.

Every light measurement in this class is already repeated five times and the repeats were never kept. sectionCount and distinctStates are declared in one @State class and therefore form a full cross product, but blockLightBySectionCount and skyLightBySectionCount never read distinctStates. Each of their figures is consequently produced once per distinctStates level, in five independent JMH runs with independent warmups — which is a direct empirical estimate of exactly the run-to-run variability that one fork does not sample. Recovering it costs one benchmark run and no code change.

Lighting an area against one neighbourhood per chunk

The design of the self-maintaining light made area forming conditional on a single number, and this is that number. µs/op:

Chunks area perChunk Area is Conservative bounds
1 542.4 ± 15.6 546.6 ± 27.4 level
4 2 678.4 ± 45.5 10 533.1 ± 162.6 3.93× cheaper 3.81×–4.06×
9 6 408.2 ± 468.2 34 390.8 ± 1 738.9 5.37× cheaper 4.75×–6.08×
16 11 607.5 ± 975.2 70 639.0 ± 1 195.9 6.09× cheaper 5.52×–6.76×

AreaVsPerChunkBenchmark.area / .perChunk, chunkCount as in the first column, a connected square of real Minestom chunks with OCCLUSION_PERCENT = 30 and SOURCES_PER_CHUNK = 4 built from SEED = 20260731, one thread, run as -f 1 -wi 3 -i 5, which matches the class annotations (one fork, 3 warmup and 5 measurement iterations of 1 s), no heap flags — this class sets none, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. MinecraftServer.init() runs in @Setup(Level.Trial), so a real block registry is in play on both sides. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

All three multi-chunk rows are supported: each pair of intervals is disjoint, so the direction and the order of magnitude hold at this precision, and the bounds column is the honest form of the ratio. The chunkCount = 1 row overlaps, which is the predicted control result and not a weakness — see below.

One caveat about state within a trial. @Setup is Level.Trial, so the chunks are built once and then lit repeatedly for the whole trial; a perChunk invocation is therefore never the first one to light those chunks. What this cannot be is cross-contamination between the two sides: JMH runs each @Benchmark method as its own trial with its own state instance, so area never sees perChunk's output or the reverse. A gap of four to six times is also far outside anything repeated lighting of the same chunks could plausibly produce in one direction only.

The criterion was that an area of n chunks has to be measurably cheaper than n separate calculateWithNeighbours calls, and that if it were not, the simpler per-chunk design would be the better one and area forming should have been dropped rather than tuned until the benchmark agreed. It holds from four chunks on and the margin grows with the area, which is the shape the argument predicted: reading the block states of a chunk and building its opacity tables is the expensive part, and a per-chunk neighbourhood reads every chunk up to nine times while an area reads each of its chunks and each of its ring chunks exactly once, no matter how large it is.

The tie at one chunk is the expected result, not a weak one. A lone chunk has no loaded neighbours, so neither side has a ring to read and there is nothing for either to save. The row is worth keeping precisely because it shows the difference is the repeated reading and nothing else.

The perChunk side used to write nine chunks and now writes one, since calculateWithNeighbours stopped darkening its ring. That removes eight toSections conversions and eight guarded writes per call, so the comparison had to be re-run rather than assumed to carry.

That re-run is one of only two genuinely independent repeats in this whole document, and it passes. At nine chunks it gives area 6 456.7 ± 842.4 against perChunk 35 342.0 ± 4 934.9 — disjoint intervals, ratio 5.47× with conservative bounds 4.17×–7.17×, against the 5.37× and 4.75×–6.08× of the row above. The two ratio intervals overlap, so the second run reproduces the first. This is the evidence that answers "one fork proves nothing" directly: a second JVM launch, after a code change on one side, landed on the same ratio interval. The third digit moved and the ratio did not — which is what two of the four repeats show, this one and the earlier IncrementalVsFullBenchmark run. The other two are both on the loader table, and neither reproduces a ratio at all: Falco's side repeats on every row of both, and Minestom's does not repeat above one thread in either, so what those two establish is an asymmetry rather than a factor. Both are set out under The region file against the one Minestom ships with.

The reason the fix barely shows is the reason area forming wins in the first place — what dominates both sides is reading the block states and building the opacity tables, not writing the result. The table above is therefore left exactly as measured, and the comparison is now between two paths that both write only what they computed correctly.

Re-run: same class, methods and parameters as the table above, chunkCount = 9 only, after the calculateWithNeighbours ring fix, same machine, same one-fork configuration, run commit and run date not recorded, no results.json committed. The ratio bounds are (b − eb) / (a + ea) to (b + eb) / (a − ea) on both runs.

A replayed block change against lighting the chunks again

One block is toggled between glowstone and air in the middle of a 5×5 world; both sides then compute the nine chunks the change marks plus the ring around them, and write the result into the same sections. The only difference is where the light of a chunk comes from: replayed onto the light the area already holds, or searched again from the block states, which is what every pass did before. µs/op:

full incremental Incremental is Kind
Block light 16 028 ± 778 7 747 ± 402 2.07× cheaper, bounds 1.87×–2.29× measured (sky = false)
Sky light 39 585 ± 1 512 7 065 ± 438 5.60× cheaper, bounds 5.07×–6.20× measured (sky = true)
One tick pays for both ≈ 55 613 ± 2 290 ≈ 14 812 ± 840 ≈ 3.7× cheaper, bounds 3.41×–4.14× derived — the sum of the two rows above

IncrementalVsFullBenchmark.full / .incremental, sky false and true, WORLD_EDGE = 5 real Minestom chunks built from SEED = 20260731 with the toggled block in the middle chunk, one thread, run as -f 1 -wi 5 -i 10 — an override of the class annotations, which are 3 warmup and 5 measurement iterations of 1 s — no heap flags, this class sets none, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. MinecraftServer.init() runs in @Setup(Level.Trial). One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

Both measured rows are supported — the intervals of each pair are disjoint by a wide margin, and the bounds column is the honest form of the factor.

The third row is not a measurement and its two assumptions should be stated. It is the arithmetic sum of the two rows above it, because a dimension with sky light pays for both kinds in the same pass. Summing them assumes that such a pass shares no work between the two kinds — an assumption about the implementation, not something the benchmark checks. Its uncertainty is the sum of the two absolute half-widths, propagated conservatively, which is why the ratio is given as ≈ 3.7× and a range rather than as the 3.75× the two sums divide to.

An independent earlier run gave 1.96× / 6.40× / 3.95×. Direction and order of magnitude reproduce; the third digit does not. This is the second of the two genuine cross-run repeats in this document — a different JVM launch, on the same machine, landing on the same three ratios to their first two digits. Together with the nine-chunk re-run above it is the whole of the project's direct answer to the single-fork objection, and the lesson both teach is the same: ratios reproduce, third digits do not.

Sky light gains far more than block light, and that is the shape the design predicts. A full sky propagation seeds every open cell of a column, which is what makes it the expensive half and what makes its scaling non-linear (see Scaling by world height). Replaying a position walks one column of the heightmap instead. Block light was never seeded that widely, so there is less to avoid.

The block toggles rather than being placed, so the world alternates between two states instead of drifting, and both directions of an incremental update are measured — adding brightness and taking it back. Measuring only the easy direction would have reported a larger number for less work.

Where the time goes in the light path

This section began as a standalone rebuild of the same call structure, run outside the project — not JMH and not the real code. The part of it that mattered most has since been measured for real: 69381af added LightEngineStageBenchmark, which times the stages of both engines inside the project. Where a rebuild estimate has been replaced by a JMH figure that is said below; the rest is still the rebuild and still carries only its ratios.

Measured, a genuine before-and-after across one commit, µs:

Stage Before 69381af After Kind
opacity — build the table 31.33 8.07 measured (falcoOpacity)
readStates 7.70 7.23 measured (falcoReadStates)
propagate — the search itself 33.85 31.41 measured (falcoPropagate)
collect 0.24 0.23 measured (falcoCollect)
falcoFull (measured, not the sum) 77.1 46.3 measured (falcoFull)

LightEngineStageBenchmark.falcoOpacity / .falcoReadStates / .falcoPropagate / .falcoCollect / .falcoFull, lightSources = 1, occlusionPercent = 0, one section, one thread, one fork, 3 warmup and 5 measurement iterations of 1 s per the class annotations — the run's own command line is not recorded — -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, commit 69381af against its predecessor, run date not recorded, no results.json committed. No ± was recorded for any cell, so no significance rule can be applied to a single row; what carries this table is the internal control described below. One fork on both sides — see Rationale: Measurement.

This table validates itself, which is why the absent error bars matter less here than elsewhere. Two of the five rows are stages the commit did not touch, and they are the noise floor of this pair of runs: readStates moves by 6 % (7.70 → 7.23) and collect by 4 % (0.24 → 0.23). Against a run-to-run floor of roughly 5 %, the opacity row moving by a factor of 3.9 is far outside anything that pair of runs could produce by chance. An unchanged stage is a control, and having two of them is what turns an unhedged table into a self-checking one.

The last row is measured separately and does not equal the sum of the four above it, deliberately. Before, the four stages sum to 31.33 + 7.70 + 33.85 + 0.24 = 73.12 against a falcoFull of 77.1, a residual of +3.98; after, they sum to 8.07 + 7.23 + 31.41 + 0.23 = 46.94 against 46.3, a residual of −0.64. Exposing that residual is what falcoFull exists for: it is the part of a whole pass the four named stages do not account for, plus whatever the decomposition itself costs.

Allocation while building the table: 74 040 → 8 664 bytes per call, from -prof gc (gc.alloc.rate.norm). Allocation counts are the most robust quantitative evidence on this page. They are essentially deterministic — the same code on the same input allocates the same bytes — so they are immune to the single-fork objection, to machine load and to JIT variance in a way no timing here is. Where an allocation figure and a timing figure support the same conclusion, the allocation is the one to lean on. The profiler output itself is not committed.

Two things fall out of this. The rebuild's estimate for SectionOpacity.of — 29.1 µs against 5–7 µs for a table without boxing — was close enough on both ends; the real path lands at 8.07 µs with a local linear-probing table over the raw state id. And the search was never the problem: at 33.85 µs propagate was already faster than the 53.9 µs figure for Minestom's search. That figure is derived, not measured directly — it is minestomFull − minestomQueue, because Minestom's search consumes the queue it is handed and cannot be run on a prepared one, which the class javadoc states. Neither operand is published with an interval, so the difference carries none either. The whole deficit against Minestom sat in the preparation.

The mechanism the rebuild identified was correct. The lambda handed to computeIfAbsent captures the BlockLightSource, so a fresh instance is created on every loop iteration, and escape analysis does not remove it because computeIfAbsent is too large to inline — 4096 objects per section, roughly 1.8 MB per chunk column.

The published rows are one of six parameter combinations the class produces. LightEngineStageBenchmark declares lightSources ∈ {1, 8, 64} and occlusionPercent ∈ {0, 30} and holds seven measured methods, so a full run yields 42 configurations. Only the five Falco stages at 1 / 0 % are published here, and the two Minestom methods (minestomQueue, minestomFull) appear only through the derived 53.9 µs above.

The rest is still the rebuild: not JMH, not the real code, run on Temurin 25, best of seven — a standalone reimplementation of the same call structure outside this repository, so none of it can be re-run from here, none of it has an interval, and "best of seven" is a minimum rather than a mean and therefore not comparable with any JMH figure above. Treat every number in the list below as an order of magnitude and a direction. The ratios between variants are what the rebuild was built to compare; the absolute microseconds are the coarsest quantities on this page. Ordered by the size of the effect, with what has since been built marked as such:

  • Done in 69381af: the opacity table without a per-block allocation. The rebuild put this at 20–45 % of the path; the stage benchmark above is the real figure.
  • Done in 69381af: collect() as one linear nibble pack instead of writing position by position through LightNibbles.set and cloning afterwards. The rebuild put it at 9.6 → 1.2 µs in the non-uniform case; LightNibbles.ofLevels now packs two neighbours at a time and range-checks once at the end.
  • Open: seeding sky light from a heightmap instead of queueing every open column cell: 79.3 → 55.1 µs, and 81 000 → 19 000 queued positions. Byte identity against the current seeding was verified over 240 randomly generated worlds, zero differing cells. This is what the non-linear sky-light scaling above is made of.
  • Open, and deliberately so: the seed pass is redundant. seed walks all 4096 positions only to find the emitters, which of already visits: 3.6 µs, plus 2.0 µs for the second byte[4096] that then becomes unnecessary. Left out of 69381af because writing the emitters during the table build changes the API of SectionOpacity and both propagators for a gain of that size.
  • Open: column opacity as one flat byte[] instead of List.get(y >> 4) plus a virtual call: −26 % on searching a whole column.
  • Open: skipping the direction an entry arrived from: −7 to −16 %. Testing the level before the opacity: −6 %.
  • A bucket queue (Dial) is 5–7 % slower at equal source brightness and 32–36 % faster at mixed brightness — both figures from the rebuild, so both are directions rather than factors. The JMH suite now produces the mixed case as well, in With sources of mixed brightness; that table shows the two 30 % solid rows holding their lead and the two open-sky rows losing theirs to overlap, which is where a bucket queue would be expected to help.
  • Done: ChunkLightState is about 100 KB per instance instead of about 980 KB. Its three working queues were sized for one entry per position of the chunk — 393 KB, 393 KB and 98 KB against the 98 KB the levels themselves take, so nine tenths of a state was scratch space for buffers that a single-block update fills a few dozen entries of. They now start at 1024 entries and grow, which is what makes a state cheap enough to be kept between two passes rather than thrown away after each, and that is what the incremental path is built on. Nine of them, which is what calculateWithNeighbours builds, come to roughly 900 KB per call instead of roughly 8.8 MB. Derived from the declared buffer sizes, not measured with an allocation profiler — as the earlier version of this bullet was, whose "roughly 28 MB" did not follow from 9 × 980 KB either. The cap on an area size is still set from this figure, because ChunkLightArea builds one state per chunk of the area and of its ring; what now dominates an area is the opacity tables, not the states — see open item 4.

Optimisations these numbers produced

Change Effect Where the figure comes from
zlib level 2 instead of the platform default 6 1.83× faster compression, ~3 % larger files No run record. See the note below
Linear-probing opacity table over the raw state id, no boxing 31.33 µs → 8.07 µs, 74 040 → 8 664 bytes per call Where the time goes in the light path; allocation from -prof gc
LightNibbles.ofLevels instead of 4096 calls to set collect 0.24 µs → 0.23 µs in the stage benchmark; the rebuild had it at 9.6 → 1.2 µs for the non-uniform case Same table; the second pair is the rebuild, not JMH
One area instead of one 3×3 neighbourhood per chunk 3.93× at 4 chunks, 6.09× at 16, level at 1 Lighting an area against one neighbourhood per chunk
Replaying a changed position instead of lighting the chunks again 2.07× on block light, 5.60× on sky light, ≈ 3.7× on a tick that pays for both A replayed block change against lighting the chunks again; the tick figure is derived
Working queues that start at 1024 entries instead of one per position ChunkLightState from roughly 980 KB to roughly 100 KB, which is what makes it keepable Derived from the declared buffer sizes, not from a profiler

Two uniform-section fast paths, and the framing they are usually given is wrong.

Path General section, distinctStates = 200 Uniform section, distinctStates = 1
Palette encode 27.9 µs 0.54 µs
Opacity table 40.8 µs 0.54 µs, and no arrays allocated

These two rows are not a before-and-after. Nothing got 51 or 76 times faster: they are the same code on two different inputs — a section holding 200 distinct block states against a section holding one — and what they show is that detecting the uniform case up front costs almost nothing compared with walking 4 096 blocks. That is a real and useful property, and it is what the fast paths were built for. It is not a speedup of the library, and a reader who parses the two columns as versions of the code will conclude something the measurement does not support.

No benchmark class, method, parameter set, machine or run configuration was recorded for these four figures, and no ±. PaletteDataBenchmark (distinctStates 1, 8, 64, 200) and SectionOpacityBenchmark (distinctStates 1, 8, 64, 200 × resolveCost 0, 50) measure exactly this shape and would produce them with provenance; whether these four came from those classes is not recorded. Treat them as design-time micro-measurements — orders of magnitude, not factors.

ChunkSaveComparisonBenchmark is published in the half that cannot settle its own claim. The class tests a sharp, falsifiable structural claim — that Minestom deduplicates palette entries with a linear search over an IntArrayList, so its cost grows with blockCount × paletteSize, while Falco uses a hash map and grows with blockCount alone — across distinctStates 1, 16, 64, 256 and 1024, with compressFalcoLevel and compressMinestomLevel built in so the compression-level difference can be subtracted out rather than argued about. Four methods over five distinctStates levels make twenty configurations, and ten of them are published: the falcoSave and minestomSave series at those five levels, five rows of two, in Anvil Chunk Loader under Measured: saving a chunk. Exactly one of the five rows resolves a difference, and that row is confounded by the compression level, because both loaders are measured as they ship and they ship different ones. What is unpublished is the compress*Level pair that removes the confound — without it the palette claim cannot be separated from the zlib level. That subtraction is the gap, and it is the cheapest one on this page to close.


Known deviations from vanilla

Vanilla defines the Anvil format, so these are gaps in this implementation, not preferences:

Gap Consequence
Heightmaps are neither written nor restored Minestom at least restores them on load
Unknown chunk-level tags are dropped structures, block_ticks, fluid_ticks and others are lost on save
entities/ and poi/ are ignored Saving a vanilla world produces inconsistent world data
level.dat is not handled loadInstance / saveInstance are not overridden
No LZ4 (type 4) or custom (type 127) compression A world written with region-file-compression=lz4 cannot be read
No corruption recovery A damaged header makes the whole region unreadable
An unknown block becomes air Better than discarding the chunk, but not what a data fixer does

Defects found and fixed

In this code

  • Block entities were stored at chunk-local coordinates. The format specifies world coordinates. The round trip through this loader worked anyway because Chunk#setBlock masks them, so only a test that read the stored NBT directly could catch it. Files were not interchangeable with vanilla.
  • Block handlers were lost on load. The id tag was written on save but discarded on load.
  • The propagation queue could overflow. It was sized on the assumption that a position is queued at most once, which is false when sources of different brightness reach the same area.
  • FalcoAnvilLoader could lose a chunk. A region file could be evicted between obtaining the handle and writing to it.
  • The name cap in AnvilDiagnostics was a check-then-act, so racing threads could exceed it.
  • The biome registry was resolved eagerly, which made a loader impossible to construct before MinecraftServer.init.
  • calculateWithNeighbours darkened the eight chunks it borrowed. It wrote all nine chunks of its 3×3 back, although only the middle one is correct: a ring chunk exchanged light inside the 3×3 and nowhere else, so whatever it legitimately receives from further out was missing from its result and its previously correct light was replaced with a darker one. It now writes only the middle chunk and reads the other eight. The middle chunk is provably unaffected by the same reasoning that makes the ring wrong — a source outside the 3×3 is at least seventeen blocks from it and no path is shorter than the direct distance, so not even level fifteen survives the trip. The fix is therefore cheaper than the defect as well as correct, which is rare enough to note. It had been an open item for a while, and what closed it was ChunkLightArea arriving at the same rule from the other side.
  • ChunkLightState#update never refilled a hole that opened six blocks from any light. When a block that had been blocking light was removed, the position carried no light of its own, so the retraction found nothing to follow; offering the sources of the chunk again reached nothing either, because their neighbours already carry exactly the level they belong at and the search stops at the first of them. Light standing six blocks away therefore never travelled into the hole. The sky path had it right — updateSky seeded the neighbours of the changed position — and update did not. Both now do. Surfaced by wiring the incremental path into the scheduler, which is what first ran update over the removal of an ordinary solid block rather than of a source; ChunkLightStateTest#testRemovingABlockingBlockLetsTheLightThrough pins it down.
  • The byte identity against Minestom was never checked by anything. This file and the documents stated "54 scenarios, byte-identical, zero differing cells" as an established fact. It rested on an ad-hoc comparison run once by hand: there was no test, and the benchmark did not verify it either, although the documentation said it did. Two agents found this independently at their own end of the code. LightEngineEquivalenceTest now runs the 54 scenarios on every build, and the benchmark checks the 2048 bytes of both engines before each trial. A number cited throughout was hanging on nothing, which is the part worth remembering — not that it turned out to hold.

Three in FalcoInstance, all found while closing the load race

Making loadingChunks the lock of a chunk position was one change; these three came out of it, each with its own test. All three are the shape InstanceContainer has, reproduced here before it was noticed that they had been.

  • unregister left a zombie chunk behind. It walked the chunk map only, and a chunk that is still being loaded is not in that map yet. The load finished afterwards and published its chunk into an instance nothing reaches any more — the permanent zombie Research: Instance Container describes. Every running load is now claimed first, which makes it discard its result, and only then are the chunks that are already there unloaded. FalcoInstanceLoadRaceTest forces the interleaving instead of hoping for it and checks the state of the instance afterwards; a test that only checked no exception escaped would have passed on the broken implementation too.
  • retrieveChunk read the chunk map outside any guard, so two loads could start for one position. The second chunk then replaced the first one in the map and the first was orphaned: still marked as loaded, still holding its tick partition and its viewers, and no longer reachable. The chunk map is now read a second time inside the compute on the position. The work itself still starts after that decision and never inside it, because a loader without parallel support runs on the calling thread and a nested compute on the same map would deadlock.
  • The map write and the tick partition could interleave, which leaves a partition ticking a chunk that was already unloaded, for the rest of the life of the server. Publishing a chunk and creating its partition are now one step taken while the position is held, and so are removing it and deleting the partition, so an unload of the same position runs entirely before or entirely after. The loaded flag is set outside that step on purpose: it calls a hook a subclass may override, and foreign code has no business running while a position is held.

Five races, all of which would have failed silently

Found by taking one known defect and searching the rest of the code for the same shape. The search turned up no second instance of that exact shape, but four races of other kinds — which is the reason it was worth doing.

The failure counts below are from the red run of each test, before its fix. They are not JMH measurements and carry no interval; each is the number of failures a named test observed in a named number of attempts on one machine, which is what makes a race visible at all — a race that fails once in fifty thousand reads is not something a mean describes. The tests themselves are the reproducible part: RegionFileConcurrencyTest, FalcoAnvilLoaderConcurrencyTest and AnvilDiagnosticsConcurrencyTest in falco-anvil, ChunkLightServiceConcurrencyTest, LightEngineConcurrencyTest and ChunkLightSchedulerConcurrencyTest in falco-light, all of which run on every build. A chart of the same figures exists but is not publicly readable, so it is not linked.

  • ChunkLightService shared its scratch buffers. It kept a ChunkLightPropagator in a field, so two threads sharing one service shared its levels and queue arrays. A probe found wrong light in ~99 % of concurrent calls. ChunkLightState had built one per call all along, which is why calculateWithNeighbours was never affected.
  • RegionFile recycled sectors while readers were still in them. readRaw took the location without a lock; a concurrent writeRaw freed the old range, which the allocator handed straight back out. Readers observed filler markers where their own payload belonged, sometimes the whole payload from offset 0. Now a per-entry seqlock: an odd counter means "in progress", and after four attempts the reader falls back to the lock so a chunk written in a loop cannot starve it.
  • .mcc files were written and deleted outside the header lock. 371 NoSuchFileException and ~60 half-read files in 54 679 reads. The payload now goes to a staging file and is moved into place with ATOMIC_MOVE under the lock, so the bytes stay outside it while the header and the file can never disagree.
  • Eviction closed a channel under a running reader. Chunk tracking starts only after decoding, so a handle could be closed mid-read: 80 of 480 loads failed with openRegionLimit = 1, 15 of 480 through the unload path, with ClosedChannelException thrown from inside FileChannelImpl.read. Handles now carry a usage count; removing from the cache and closing are separate, and the last user closes.
  • closed was set but never read. loadChunk, saveChunk and region() ignored it, so a load still running at shutdown opened fresh handles into the map close() had just cleared — a descriptor leak, and writes into a world already considered closed. They now throw IllegalStateException, which is a lifecycle error of the caller rather than a data error.

The reason all five mattered is that none of them announced itself: the light path clears the section's update flag, so the server never recomputes what two threads corrupted, and a read failure that returns null makes Minestom regenerate the chunk and overwrite the real data on the next save.

A sixth, on Windows only, and older than the five

RegionFile wrote and deleted the external .mcc file of an oversized chunk in a way that let a concurrent reader block it. The external file is the one place where the lock-free reads meet 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 only marks the file delete-pending: as long as one reader holds it open, every later open of that name and every move onto it is denied. An inline writer therefore poisoned the name for the writer that wanted to put a new external file there.

Fixed in 78e196c: the file is renamed onto a private name with ATOMIC_MOVE and only then deleted, because a rename detaches the name immediately on both systems. What Windows can still deny briefly on its own — a handle being torn down, a virus scanner holding the file — is retried for a bounded time.

This one is older than the concurrency fixes of this week. It was already in the last green commit; there simply was no test that exercised it. The loader was therefore broken on Windows as soon as an oversized chunk is read while it is being saved. Only the CI runner could show it — on Linux it is not reproducible, and the platform semantics are what the fix had to be reasoned from.

That is also why .github/workflows/build-pr.yml now uploads the test reports as an artifact when a build fails (bec8b67). Gradle's console summary names the test class and the exception type, but neither the message nor a path nor a stack trace, and on a platform-specific failure that is the difference between reading the cause and guessing it.

In Minestom, avoided here

Length field written as 5 + N instead of 1 + N; status in lower case where the game writes Status; the return value of read ignored; an unknown block turning into an NPE that discards the whole chunk; block entities dropped in uniform sections; the read failure path returning null, which makes the server regenerate the chunk and overwrite the real data on the next save.


Open

Ordered by consequence, not by effort.

1. Exception hierarchy

Design complete in Research: Exception Hierarchy, six types, not implemented. One open decision: whether the checked root extends IOException. Extending it keeps roughly 40 signatures and 14 test assertions untouched; not extending it stops every existing catch (IOException) from silently swallowing the new types. Both arguments hold — this needs a call, not more analysis.

2. calculateWithNeighbours can commit a stale read of its neighbours

One service may now serve any number of threads, which is what the light fix established. What it does not establish is two threads lighting overlapping neighbourhoods: each reads the block states of all nine chunks separately, and a chunk one thread reads as a neighbour is a chunk the other may be writing. Neither corrupts memory — every write holds the chunk's write lock — but a result can be committed from a read that was already stale, which shows up as a seam rather than as an error. Whether that happens is up to the caller; nothing in the API says so yet.

Narrowed twice, still not closed. Since the method writes only the chunk in the middle of its 3×3, two threads on different coordinates no longer write the same sections at all, so the plain last-writer-wins case is gone; what remains is the stale read. And a caller going through ChunkLightScheduler is not exposed to either: a chunk under computation stays marked but is not submitted a second time, and a chunk whose mark count moved while its area ran has its result discarded rather than written. ChunkLightArea says the same thing at its own level — it is safe to share between threads, but two threads computing overlapping areas is not made safe there either, and the scheduler is what keeps that from happening. The item stands for everyone who calls the service directly.

3. ChunkLightArea does not read the diagonal chunks of its ring

The ring of an area is built from the face neighbours of every chunk in it, so a chunk that touches the area only at a corner is never read. A light source in such a chunk reaches the area through the chunk between the two, and that chunk is in the ring — but only its own light is exchanged, and its own light does not contain what it received from its diagonal neighbour, because a ring chunk's state is computed from its block states alone. The source is therefore missing from the result.

This is the same family as the ring darkening just fixed in calculateWithNeighbours — a chunk whose light matters is not being read — but smaller, and in the newer type. Smaller because it costs a level: a source in a diagonal chunk has travelled at least one chunk before it arrives, so what it can still contribute at the edge of the area is bounded, and it is invisible in every test that places its sources inside the area or on a face.

The obvious fix is to add the four diagonals of every area chunk to the ring, which for a one-chunk area is exactly the 3×3 calculateWithNeighbours reads. That is also the trade: the ring grows, and the ring is what every pass pays for. It is the reason calculateWithNeighbours was not deprecated in favour of ChunkLightArea — for a single chunk the older method is still the more accurate of the two, and the area is the cheaper one for several.

4. The opacity tables are rebuilt on every pass

ChunkLightArea#read calls ChunkLightService#opacityOf for every chunk of the area and of its ring, on every pass, whether or not anything in that chunk moved. Building those tables is the expensive part of lighting — that is the whole argument area forming rests on — so now that the light itself is kept between passes and a changed position is replayed onto it, this is the largest remaining cost block by a wide margin.

Only the sections a change touched need a new table. The rest is being rebuilt from block states that are identical to the ones the previous pass read, which is work whose result is known in advance. A ring chunk that nothing marked is the clearest case: it is read once per pass, per kind of light, purely to be handed to a border exchange that will produce the same border it produced last time.

Keeping the tables has the shape the kept light already has — a bound, a least-recently-used eviction, and an invalidation driven by the same reported positions — so the mechanism exists and would be reused rather than invented. What it has to answer that the kept light does not is that a table is per section rather than per chunk, so the bound is in sections and the invalidation has to map a position to one.

5. Two things that are argued rather than tested

  • Stale header entries. locations and timestamps are AtomicIntegerArray now, so a reader cannot see a stale 0 and turn a present chunk into a regenerated one. That is ruled out by construction, not by a test — a JMM staleness window cannot be provoked deterministically, because any harness that tries introduces synchronisation edges of its own.
  • The open-handle limit is no longer a hard cap on descriptors. It bounds the cached files; a handle in use by a thread stays open beyond it for the duration of that access. Deliberate, and documented at the field, but it means the limit is a cache size and not a resource guarantee.

6. Smaller items

  • Border exchange between chunks settles one ring deep; a fully converged result over a large area needs the exchange repeated. The part of this that was outright wrong — writing the ring back — is fixed; item 3 is the part that is still a gap in what gets read.
  • An area split at maxAreaSize leaves a seam between its two parts for one tick, because each part reads the other as its ring and only the next pass settles it. Deliberate — the alternative is an unbounded area — and it is the reason the cap is a constructor argument rather than a constant.
  • ChunkLightScheduler serves exactly one instance and refuses a second with an IllegalStateException. That is the honest behaviour given a dirty set keyed by chunk coordinates alone, but it means a server with several worlds builds one scheduler per world and nothing in the type system says so.
  • FalcoInstance#unregister gives up after four sweeps and logs what it left behind rather than looping until the world stops changing. A caller that keeps requesting chunks during a shutdown is a caller error and is not covered; a shutdown that never returns would be worse than one that reports a leak.
  • A full sky propagation still queues every open cell of a column rather than seeding from a heightmap. Measured at 79.3 → 55.1 µs in the rebuild, with byte identity verified over 240 worlds. An update no longer does this — ChunkLightState keeps the height at which each column stops the sky and walks only the column that moved, which is most of why the incremental sky figure under Measured is the larger of the two gains — but every propagation that starts from block states still pays it.
  • SectionOpacity still builds its table unconditionally for non-uniform sections. Since 69381af that costs 8.07 µs instead of 31.33, measured in Where the time goes in the light path and carrying no interval there, and the empty section with one source is no longer the row that loses — but the table is still built whether or not it is read more than once. Item 4 is the same waste one level up: not "is this table read twice" but "was this table already built last tick".
  • seed walks all 4096 positions a second time only to find the emitters. Writing them during the table build saves about 3.6 µs in the rebuild, plus the 2.0 µs of a second byte[4096] that then becomes unnecessary, at the price of changing the API of SectionOpacity and both propagators. Both figures are from the rebuild and neither carries an interval. Left open on purpose for that reason.
  • Column opacity is a List.get(y >> 4) plus a virtual call rather than one flat byte[], and the search neither skips the direction an entry arrived from nor tests the level before the opacity. All three figures come from the standalone rebuild and are directions rather than factors: −26 % on the time to search a whole column, −7 to −16 % on the same baseline, and −6 %. None was measured by this suite and none carries an interval.

Investigated and deliberately not built

The rejections are the part of this record that is hardest to fake and easiest to check, so each one below states what it was rejected on: a measurement, a structural property of the source, or neither. "Neither" appears once and is written as such rather than dressed up as a finding.

Replacing parts of Minestom

Three "replace this part of Minestom" questions were researched before any code was written. The answers differed sharply and none was obvious in advance. These three are questions, not documents: Research catalogues five documents, of which two came later and are not part of this round, and the palette answer below has no document of its own — it is a compiler result, recorded here and nowhere else.

Subject On what basis Verdict
Palette Structural, verified with javac and at runtime Impossible. sealed interface Palette permits PaletteImpl is a hard compiler error, and Section is a record holding that exact type.
InstanceContainer Structural for the "possible" half, measured for the "pointless" half Possible but pointless as asked. It compiles and runs, but four instanceof InstanceContainer sites silently take another path for a foreign type, and the tick parallelism the request targeted lives in the global ThreadDispatcher, not in the container. falco-instance was built later for a different reason and still claims none of the speed the original question was about — see Instance above.
Light engine Measured, but by a standalone probe rather than by this suite — the load-path breakdown in Research: Light Engine put the two workloads that a faster engine touches at 2.2 ms per relit chunk and 0.586 ms per block placement, against 0 % for loading a pre-lit world. That probe recorded no machine, no run configuration and no uncertainty, and cannot be re-run from this repository; Research says so of every number in those documents. What the figures established is which workload to look at, not how much was there to win Possible and worth it — this is what was built.

The recurring lesson: sealed-ness decides whether it is possible, and the profile decides whether it is worth it. Both have to be checked before designing anything, and neither can be guessed.

Importing a foreign light algorithm

A later round asked whether an algorithm from another engine would close the gap to Minestom that existed at the time. None would have: the gap was not in the search but around it, and the stage benchmark has since supported that — propagate measured 33.85 µs before the change, against a derived 53.9 µs for Minestom's search, and closing the gap took a table without allocations rather than another algorithm. Claims about other light engines refutes the individual leads by reading those engines' sources. The verdicts below exist so that nobody walks the same road again.

Subject On what basis it was rejected Verdict
Starlight, wholesale Structural, by reading SWMRNibbleArray and the vanilla storage it replaces Nothing left to take. The push-instead-of-pull BFS is already here, and the remainder of Starlight's gain is specific to how vanilla stores light.
Bit-slicing light levels across voxels Neither. Nothing was measured and nothing structural rules it out No precedent in any engine, so it would be an original design with an unproven benefit and no reference implementation to compare against. This is a decision not to spend the effort, not a finding.
Parallelising the BFS of a single chunk Measured, from the round of investigation that produced the 50–150 µs figure — a probe figure with no recorded machine or configuration, like every number in Research. It does not have to carry on its own: the order of magnitude is confirmed by every published row of Against the engine Minestom ships with, whose twelve Falco after and Minestom cells put one section's whole light path between 39.3 and 206.6 µs/op, every one of them with a half-width under 5 % of its own mean Not worth it. Handing a unit of work that size to another thread costs more than it saves. Minestom parallelises across chunks, which is the right granularity.
Vector API Structural, and about packaging rather than performance Ruled out regardless of what it might buy: without --add-modules jdk.incubator.vector javac refuses, with it the runtime prints an unsuppressable warning, and a JAR manifest has no attribute to carry the flag — so every consumer would have to set it. No performance measurement was taken, and none would change the answer.
A bucket queue (Dial) Measured in the standalone rebuild only — no JMH measurement of a bucket queue exists No longer undecided for lack of an instrument. The rebuild puts it at 5–7 % slower with sources of equal brightness and 32–36 % faster with mixed brightness; both are rebuild figures, so directions rather than factors. Since 0e8fbb5 the JMH suite produces the mixed case as well: in With sources of mixed brightness the two rows with solid blocks keep a supported lead of about 1.7×, while both open-sky rows fall to overlapping intervals, which is where a bucket queue would be expected to help. The prerequisite is met; the change itself is still not made.

Documents

Page Contents
Anvil Chunk Loader Usage, architecture, 20-row comparison with the built-in loader, limits
Light Engine Usage, design, where resources are saved, limits
Benchmarking How to run the benchmarks and what each measures
Rationale: Measurement What the ± means, what one fork does and does not cover, and why the numbers on this page are believable at the precision they are stated
Research The five research documents, how each was produced, and how much weight its findings carry — the structural ones do, the probe numbers do not

References

Sources for the claims on this page, all readable without running anything.

  • Benchmark conditions — @Fork, @Warmup, @Measurement, @Param and jvmArgsAppend are explicit on every class in falco-benchmarks/src/jmh/java. Where a table above was produced with a command-line override, its provenance line says so; the annotation is what the class declares and the override is what the run used.
  • Build and dependency wiring — settings.gradle.kts for the version catalog and the six modules, falco-benchmarks/build.gradle.kts for the JMH configuration, check depending on compileJmhJava and nothing depending on jmh.
  • Byte identity between the two light engines — LightEngineEquivalenceTest for the 54 scenarios on every build, and verifyBothEnginesAgree in LightEngineComparisonBenchmark for the per-trial check.
  • Chart data — docs/charts/generate.mjs, where every bar is a literal that can be compared against the table it claims to draw.
  • Minestom is cited throughout as a path relative to net/minestom/server/ plus a line number at version 2026.06.20-26.1.2; Falco by member name. The reason the two conventions differ is in Anvil Chunk Loader.

What is not here. No results.json, no human.txt and no -prof gc output from any published run has been committed. No run date is recorded for any of the eight sections under Measured, and a commit is recorded for only three of them — the two light comparison tables and the light stage table; the other five, and the two uniform-section fast-path figures under Optimisations these numbers produced, have neither. A command line is recorded for three of the eight — the UNIFORM light comparison, the area comparison and the incremental comparison; on a fourth, the region file table, earlier documentation stated an iteration time the class annotation contradicts and no command line survives to settle it. Each provenance line says this at its own table rather than only here. The machine is described only as a 16-core machine that was not idle — no CPU model, no frequency policy, no JVM build, no kernel. Those gaps are listed field by field under Measurement environment and are the reason every table on this page states its conditions inline rather than pointing at a run record.

Clone this wiki locally