diff --git a/docs/contributing/benchmarks-and-demo.md b/docs/contributing/benchmarks-and-demo.md index 75f534f..f52174d 100644 --- a/docs/contributing/benchmarks-and-demo.md +++ b/docs/contributing/benchmarks-and-demo.md @@ -112,6 +112,32 @@ A benchmark that stopped compiling after a refactoring elsewhere in the codebase build like any other source set would. Nothing in the build depends on the `jmh` task itself, so `./gradlew build` never runs a benchmark and a published artefact never contains one. +## The CI benchmark workflow + +The **Benchmark** workflow +([`.github/workflows/benchmark.yml`](https://github.com/OneLiteFeatherNET/Falco/blob/main/.github/workflows/benchmark.yml)) +runs the same jar on demand and uploads four things: `results.json`, the readable output, **the exact +command line as executed**, and a filled-in provenance line. Two profiles: + +| | What it does | +| --- | --- | +| `custom` | one JMH filter in one job — for re-running a single table | +| `full` | the whole suite across sixteen shards, about half an hour where a sequential run is five | + +Its purpose is provenance, not precision. Many of the gaps listed in +[Reference Measured results](../reference/measured-results.md) are of the form *no run record was kept* rather +than *the number is wrong*, and those close with a run that documents itself even from a machine that measures +worse. Leave `forks` empty to keep each class annotation, which is what re-running a published table needs. + +The workflow enforces one rule rather than advising it: **a shard is a whole class.** Shards run on separate +runners, so splitting a comparison class would put `falcoRead` and `minestomRead` on different CPUs and the +resulting ratio would measure the hardware. That happened once, at 2.13× against the 1.13× one machine gives. + +A shared runner is not the machine the published tables came from, and the difference is not only in absolute +terms: the same light comparison measures 1.35× there against 1.13× here. CI figures therefore belong in their +own table with their own provenance line, never merged into one measured elsewhere; the reasoning is in +[Explanation What a measurement here means](../explanation/what-a-measurement-here-means.md#the-limits-stated-without-softening). + ## `falco-demo` `falco-demo` is a runnable comparison of the Falco and the Minestom chunk loader on the user's own diff --git a/docs/contributing/build-setup.md b/docs/contributing/build-setup.md index 9b9ccee..fb1639f 100644 --- a/docs/contributing/build-setup.md +++ b/docs/contributing/build-setup.md @@ -187,3 +187,11 @@ japicmp the task rather than its archive produces the same silent pass. A `doFirst` guard fails the task if the baseline ever resolves into this build's own `build/libs`. Both mistakes above had one signature — a green check that verified nothing — and a compatibility gate that cannot fail is worse than none, because it reads as coverage. + +## Credentials and a 401 during resolution + +Building Falco needs `ONELITEFEATHER_MAVEN_USERNAME` and `ONELITEFEATHER_MAVEN_PASSWORD`, because +Minestom and `mycelium-bom` are resolved from an authenticated endpoint. A 401 during dependency +resolution means those two variables are unset or wrong. It is the failure that actually occurs, not a +repository outage. Consumers of the published artefacts never see it, since their endpoints are public; +see [Publishing](publishing.md) for how the credentials are wired into the build. diff --git a/docs/contributing/versioning-and-releases.md b/docs/contributing/versioning-and-releases.md index 3fd647e..6583c94 100644 --- a/docs/contributing/versioning-and-releases.md +++ b/docs/contributing/versioning-and-releases.md @@ -116,3 +116,10 @@ There is deliberately no tag-triggered publish workflow next to this one. Releas the default `GITHUB_TOKEN`, and a tag pushed by that token does not re-trigger workflows in the same repository — so a tag-triggered publish would either never fire or race this job. The comment in the workflow file records the same reasoning and is the authority if the two ever disagree. + +## Why consumers take the version from the BOM + +Every module of a release is cut from the same version number, and `falco-bom` carries that one +number for the modules it constrains. A consumer that versions each module by hand can end up with a +combination that was never built or tested together. The BOM is the way to avoid that; see +[How-to Add Falco to your build](../how-to/add-falco-to-your-build.md). diff --git a/docs/explanation/choosing-between-falco-and-the-built-in-loader.md b/docs/explanation/choosing-between-falco-and-the-built-in-loader.md index 72974c5..9d5a443 100644 --- a/docs/explanation/choosing-between-falco-and-the-built-in-loader.md +++ b/docs/explanation/choosing-between-falco-and-the-built-in-loader.md @@ -7,6 +7,18 @@ carry a decision. Not every row below is a reason to switch. All references point at the sources of the Falco repository and at Minestom `2026.06.20-26.1.2`. +## Both switches are opt-in + +Falco replaces Minestom's chunk loader and hands light computation to a scheduler, and both are opt-in: +nothing in Falco switches itself on. A server keeps its current loader until it constructs a +`FalcoAnvilLoader` and sets it on an instance, and it keeps its current light engine until it sets a +`ChunkLightScheduler` supplier. The two choices are independent of each other, so a server can take one +and not the other. + +The loader choice is reversible. A provider that keeps a world root per map passes that root and the +dimension key of the instance it is building. Any other `ChunkLoader` is supplied the same way, so +switching back to the built-in loader changes which `ChunkLoader` is constructed and nothing else. + ## Comparison with the built-in AnvilLoader **How the two sides are referenced.** Minestom references are a path relative to diff --git a/docs/explanation/how-the-concurrency-design-works.md b/docs/explanation/how-the-concurrency-design-works.md index 2614c0f..0406e54 100644 --- a/docs/explanation/how-the-concurrency-design-works.md +++ b/docs/explanation/how-the-concurrency-design-works.md @@ -400,6 +400,14 @@ What they do not establish is stated in the same spirit [1]: last-writer-wins case is gone and the stale read is what is left. Whether it happens is up to the caller, and nothing in the API says so yet [1]. +## One light service serves every thread + +`ChunkLightService` holds exactly one field, the `BlockLightSource` it was constructed with, and every working +buffer lives in a `ChunkLightPropagator` built inside the call. A single instance may therefore be used by as +many threads as you like, and that is a property of the class layout rather than of a benchmark. `LightPropagator` has no such property: it keeps its buffers +in instance fields, so it stays confined to one thread; the consequences are in +[How-to Compute light for a loaded world](../how-to/compute-light-for-a-loaded-world.md). + ## Sources 1. [Project Status](../project-record/project-status.md), the working record: established facts, the five races and their diff --git a/docs/explanation/how-the-light-engine-works.md b/docs/explanation/how-the-light-engine-works.md index 26114ac..d126122 100644 --- a/docs/explanation/how-the-light-engine-works.md +++ b/docs/explanation/how-the-light-engine-works.md @@ -12,8 +12,8 @@ workload at all is [Explanation When light computation actually runs](when-light ## Design Eight types compute the light, each with one responsibility. Only the two on the right know Minestom -exists. A further five, listed [further down](../how-to/keep-chunk-light-up-to-date-automatically.md), decide *when* it -is computed and never how. +exists. A further five, listed in [Reference API surface and stability](../reference/api-surface-and-stability.md#light-engine-types), +decide *when* it is computed and never how. ``` ┌─ engine, no Minestom ─────────────┐ ┌─ adapter ──────────────┐ @@ -191,6 +191,166 @@ only the transfer of the result takes the write lock which brackets its loop with `chunk.lockWriteLock()` and `chunk.unlockWriteLock()`). +## Light across chunk borders + +### Sky light + +Sky light enters from above and falls straight down **without losing a level** until something stops +it — which is why an open field is fully lit at every height while a cave is dark. Only after the +fall is interrupted does it spread like any other light, losing one level per block. + +### Why a chunk alone is not enough + +Lighting a chunk on its own ends its light at the border, which shows up as a straight dark line +every sixteen blocks. `ChunkLightService#calculateWithNeighbours` exchanges the border levels with every +already loaded neighbour in both directions, and **writes only the chunk in the middle**. Neighbours that +are not loaded are skipped rather than forced to load. + +One round of that exchange is not enough. A source in the corner of a chunk sends light through two +borders, and the light that entered a neighbour has to leave it again on another side to arrive in +the chunk diagonally behind it. The exchange therefore repeats over the whole area — the chunk and +the eight positions around it — until no chunk of it raises a level any more: + +| Property | How it is reached | +| --- | --- | +| Terminates | An injection only ever raises a level, and a level is capped at fifteen, so the repetition walks towards a fixed point. | +| Same result every time | The area is a fixed-size array walked in a fixed order, not a map. Since every step only raises levels, the fixed point does not depend on the order either. | +| Reads a chunk once | The opacity tables of every participating chunk are built once, before the first round, and reused by all of them. | +| Cannot loop forever | The amount of rounds is capped at sixteen, which is one more than the highest level that can exist. Hitting the cap is reported through `LOGGER.warn` instead of being accepted silently. | + +A radius of one chunk is enough because a level of fifteen cannot survive sixteen blocks of travel, +so nothing the middle chunk emits can reach a second ring. + +**The eight chunks around the middle are read and never written, and that is not a shortcut.** They +only exchanged light inside the 3×3, so whatever they legitimately receive from outside it is missing +from their result; writing that back would replace their correct light with a darker one. The middle +chunk does not have the problem, and provably so: a source outside the 3×3 is at least seventeen +blocks away from it and no path can be shorter than the direct distance, so not even a level of +fifteen survives the trip. Writing one chunk instead of nine is therefore cheaper *and* correct. The +method used to write all nine, which is the defect this replaces. + +**What this method still has over a one-chunk area.** The 3×3 includes the four **diagonal** chunks; +the ring of a [`ChunkLightArea`](../reference/api-surface-and-stability.md#light-engine-types) is built from face neighbours +only. A source in a diagonal chunk reaches the middle chunk through the chunk between them, and the +neighbourhood carries that — an area of a single chunk never reads it. For one chunk this is +therefore the more accurate of the two calls; for several connected chunks the area is the cheaper +one, because it reads every chunk once instead of once per neighbourhood. That an area misses its own +diagonals is a gap of its own, and it is recorded in [Project Status](../project-record/project-status.md) rather than +glossed over here. + +## Incremental updates and what they keep + +### A change marks the 3×3, not just the chunk it happened in + +A lamp on the eastern edge of a chunk belongs in the light of the chunk east of it, and that chunk would +otherwise never be told. The ring around an area is the mirror image of the same rule: it is read so the +edge of the area is right, and never written, because a ring chunk has not seen what lies on its own far +side. + +### A changed block costs a changed block, not nine chunks + +`setBlock` reports the *position* that changed, not merely that its chunk is dirty. The area keeps the +light of every chunk it has computed and replays the reported positions on it, so a placed torch costs one +incremental update rather than nine full chunk searches. The eight neighbours are still marked, because +light crosses borders, but nothing of theirs is discarded: their own blocks did not move, and what arrives +across the border is derived again by every pass anyway. A change that cannot be placed — a chunk that was +generated, loaded, or written past `setBlock` — is reported as being of unknown extent, and that chunk is +searched again. What this is worth is measured by `IncrementalVsFullBenchmark`; the measured figures and the +reading of their intervals are in +[Measured results](../reference/measured-results.md#a-replayed-block-change-against-lighting-the-chunks-again) +and [What a measurement here means](what-a-measurement-here-means.md). + +### What is kept + +Incremental light updates keep the light of a chunk *alone*, before any border exchange, and that is what +makes the hard direction tractable. Taking light back is the case an incremental engine gets wrong, because +a retraction has to run until light from somewhere else legitimately takes over — and a retraction that had +to leave the chunk to find that point would need the neighbours retracted with it. It never has to here: the +kept light contains nothing from any neighbour, so every level in it originates inside the chunk and the +retraction is complete at the border by construction. The light that crosses borders is not stored at all; +it is derived again on every pass, from a *copy* of the kept light, and an exchange only ever raises levels +and so cannot carry a stale glow forward. + +The kept light is bounded: at most 128 chunks by default (`ChunkLightArea.DEFAULT_MAX_CACHED_CHUNKS`), least +recently used first. Dropping an entry costs a full propagation and nothing else. The result is the same bytes +a full recalculation produces, and a chunk that cannot be followed incrementally is simply propagated again. + +### When the scheduler computes + +**Areas are formed once per tick and capped.** `Chunk#tick(long)` runs per chunk, but a pass has to +see every change of the tick before it groups anything, so the scheduler runs its pass for the first +chunk reporting a timestamp it has not seen. Connected dirty chunks are lit together, and the group +is closed at `maxAreaSize` chunks, 16 by default, with the remainder starting the next area. The cap +is there because every chunk of an area and of its ring is read and turned into opacity tables inside +one tick. The seam between two parts settles on the following tick, because each part reads the other +as its ring. The constants and what is not measured about them are in +[Scope and non-goals](scope-and-non-goals.md#the-light-engine). + +**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. A chunk whose area is still running stays +marked but is not submitted again, and a chunk that changed while its area ran has its result +discarded and stays dirty rather than being written from block states that are already gone. + +### Batches + +`AbsoluteBlockBatch#apply` ends by calling `sendLighting()` on every touched chunk that is a `LightingChunk` +and skips every other type, so a `FalcoLightingChunk` would never be resent by it. It does not have to be: a +batch writes through `setBlock`, which marks the chunk here, so the next tick lights the whole touched region +and sends it. The result arrives one tick later than Minestom's would, and it arrives for the ring around the +batch as well, which Minestom's path does not manage. + +### Retracting light + +Adding brightness is straightforward — it only spreads. **Removing** it is the hard case and the +reason this class exists: when a light source disappears, the brightness it had spread is still +stored in every block around it, and spreading again would keep that glow forever. The update +therefore runs two passes. The first retracts every level that originated from the changed position +and collects the still valid levels it meets at the edge of the retracted area; the second spreads +those back in. + +Which way an update goes, drawn out: + +```mermaid +flowchart TB + C["a block changed at x, y, z"] --> K{"is the position
brighter or darker than before?"} + K -->|brighter| S["one pass: spread outwards.
Levels only ever rise, so nothing
has to be taken back"] + K -->|darker| R1["pass 1: retract.
Walk outwards and clear every level
that came from this position"] + R1 --> R2["at the edge of the cleared area,
collect the levels that came
from somewhere else"] + R2 --> R3["pass 2: spread those collected
levels back in"] + S --> D["result is identical to
a full recalculation"] + R3 --> D +``` + +The second pass is not a correction of the first. The light of every *other* source in the +neighbourhood is legitimate and was cleared along with the rest simply because it stood in the way; +collecting it at the edge and letting it back in is what puts it back. Skipping the retraction +instead and only spreading again would leave the removed source's glow in place for good. + +`ChunkLightStateTest#testTheIncrementalResultMatchesAFullRecalculation` asserts that the incremental +result is identical to a full recalculation, block for block. + +### Sky light updates + +Sky light has an origin no block holds: it falls in from above. An update can therefore not tell +from the levels alone which positions lost their origin and which gained one, and a state that holds +sky light keeps a heightmap for that reason — the highest position that stops the sky, per column. + +A block change moves exactly one column of that heightmap, and the difference between the old and +the new height names the positions whose origin changed: + +| Change | Effect on the column | +| --- | --- | +| A block is placed above the current height | Everything between the old and the new height falls out of the open sky and gives its level back. What is left is refilled from the sides, which is why a single pillar leaves a level of fourteen below it rather than darkness. | +| The highest blocking block is removed | The column opens down to the next block below, and every position in between receives the full level again and spreads it. | +| The change is below the height | The height stays where it is. The changed position is retracted and refilled from its neighbours, exactly as a block light update works. | + +Only the changed column is walked again, so an update no longer re-seeds all two hundred and fifty +six columns of the chunk. + +`SkyLightUpdateTest` asserts the result against a full recalculation block for block, for both +directions, for a change that is not in the highest blocking position, and for a seeded sequence of +random changes that verifies the equality after every single one of them. + ## Sources Falco sources are cited by member name throughout, and every type of the engine lives under diff --git a/docs/explanation/how-world-migration-works.md b/docs/explanation/how-world-migration-works.md index 07bd44e..546c190 100644 --- a/docs/explanation/how-world-migration-works.md +++ b/docs/explanation/how-world-migration-works.md @@ -134,6 +134,11 @@ same chunk twice: refused without migration, loaded with it. ## The rules, and where their numbers come from +Migration only fixes what someone has written down. A block whose renamed or removed name has no rule +still becomes air after migration, because nothing in the engine can guess its replacement. The rules +are therefore the part of the engine that needs maintenance, and every number in the rule file carries +its source in a comment beside it. + [`BlockStateRules`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java) holds the block-state changes. Each rule carries a `since()`: the `DataVersion` the change happened in, so a rule applies exactly when `since() > sourceVersion`. diff --git a/docs/explanation/what-a-measurement-here-means.md b/docs/explanation/what-a-measurement-here-means.md index 0640eeb..684990f 100644 --- a/docs/explanation/what-a-measurement-here-means.md +++ b/docs/explanation/what-a-measurement-here-means.md @@ -814,6 +814,31 @@ command lines for every published table live in one place — [How-to Reproduce What a reproduction of yours establishes, and what it does not, is the subject of the rest of this page. +### What agreement would look like + +A replication is not expected to reproduce these numbers. It is expected to reproduce these +conclusions, and the criteria are stated in advance so that neither side gets to decide after the +fact. + +- **A row marked *supported* agrees** if the direction is the same and your conservative ratio bounds + overlap the ones printed in the table. It **disagrees** if the direction reverses, or if your + bounds are disjoint from ours — which would mean one of the two machines is measuring something the + other is not. +- **A row marked *no difference resolvable* agrees** if your intervals also overlap, and is a *new* + result if yours are disjoint. Resolving a difference we could not is an improvement on this page, + not a contradiction of it. +- **A row marked *not usable* agrees** if your slower side is also dispersed — an error bar that is a + large fraction of its own mean. A run in which Minestom's read time stays tight under four or eight + concurrent readers would be a genuine refutation of the loader's central claim, and it is the + single most valuable measurement anyone outside this project could contribute. +- **Absolute microseconds are not expected to agree at all.** Do not report a mismatch in them as a + failure to reproduce; report the ratio. + +Ratios reproduce, third digits do not. Two of the five repeats this project has run behaved that +way; on the other two, both on the loader table, Falco's numbers reproduced and Minestom's did not. +A fifth that behaves differently from any of them is worth hearing about. The repeats themselves are +listed under [Reference Measured results](../reference/measured-results.md#measurement-environment). + ## What this all adds up to The honest summary is narrower than the headline factors, and it is the summary that should be diff --git a/docs/explanation/why-falco-instance-exists.md b/docs/explanation/why-falco-instance-exists.md index 46b232f..30d00b8 100644 --- a/docs/explanation/why-falco-instance-exists.md +++ b/docs/explanation/why-falco-instance-exists.md @@ -245,6 +245,13 @@ deliberate: the chunks *are* the container's, and a view that loaded its own wou view. `loadOptionalChunk` is overridden only so that the per-view auto-load decision is the one that answers. +What the view does not change is who owns the blocks: `setBlock` reaches the container, which serialises +every write on its own monitor. A world that needs Falco's write path therefore uses `FalcoInstance` and +gives up sharing. The order of construction matters as well: `InstanceManager#createInstanceContainer` +registers the container, and the view's constructor refuses a container that is not registered. +`InstanceManager#registerSharedInstance` performs no such check, and an unregistered container is ticked by +nobody. + ### The chunk constructor and the empty viewer list `Chunk`'s constructor computes the shared instances whose players must also see the chunk @@ -460,6 +467,43 @@ be said from the source alone is that the two structures make opposite trade-off has measured which one wins for chunk streaming. If that ever matters, it needs a benchmark run, not an argument — the benchmark classes exist since 2026-08-03, the run does not. +## Owning the loader and saving on shutdown + +`registerAndShutdownWith` is the form worth preferring when the instance owns its loader. `ownsLoader(true)` +means the instance closes the loader on shutdown, `saveOnShutdown(true)` means it writes its chunks first, +and the shutdown task is registered for you. The three together are the part that is easy to get wrong by +hand, because a loader closed before the save loses the save. + +## The light engine on the instance + +A `ChunkSupplier` produced by `ChunkLightScheduler#supplier()` makes `FalcoLightingChunk`s, which extend +`FalcoChunk`. Each one reports its block changes, loads and ticks to the scheduler, and the scheduler lights +the touched regions and sends them. The application writes no listener of its own. + +`falco-light` declares `falco-instance` as `compileOnly`, so adding the light module does not bring this one +with it. An application declares both, or uses the BOM; see +[How-to Add Falco to your build](../how-to/add-falco-to-your-build.md). + +## Events and the chunk lifecycle listener + +**Minestom's own events still fire, and for most application code they are the right thing.** +`FalcoInstance` dispatches `InstanceChunkLoadEvent` and `InstanceChunkUnloadEvent`, and the block +writer dispatches `PlayerBlockBreakEvent`, so a listener registered on the `GlobalEventHandler` +works here exactly as it does on an `InstanceContainer`. Take those unless you have a reason not to. + +`ChunkLifecycleListener` is the other thing, and the difference is *when*, not *what*: + +| | `ChunkLifecycleListener` | `InstanceChunkLoadEvent` | +| --- | --- | --- | +| when | during the transition, inside the lock | after it, once the chunk is published and the future completed | +| may call back into the instance | mostly no — stated per method | yes | +| a throw | fails the chunk load and reaches the caller waiting on it | does not | +| per block written | `onBlockChange`, no allocation | no equivalent | + +Take the listener when you must act *before* anybody else sees the chunk, or when you need per-block +notification — which is what the light engine uses it for. Take the events for anything else. The methods +of the listener are listed in [Reference API surface and stability](../reference/api-surface-and-stability.md#chunk-lifecycle-listener). + ## What this module deliberately does not attempt Each of these is a refusal rather than a gap that nobody got round to, and each throws or fails to diff --git a/docs/how-to/add-falco-to-your-build.md b/docs/how-to/add-falco-to-your-build.md index 884b9b1..80b21f9 100644 --- a/docs/how-to/add-falco-to-your-build.md +++ b/docs/how-to/add-falco-to-your-build.md @@ -1,10 +1,12 @@ # Add Falco to your build Declare the Falco modules your project needs so that they resolve from the OneLiteFeather -repository. This applies to any Gradle or Maven project that already targets Minestom. +repository. You end with a build that compiles against `falco-anvil`, `falco-light` or the other modules +you chose. This applies to any Gradle or Maven project that already targets Minestom. **Before you start:** a Minestom project that compiles. No credentials are needed — the release and -snapshot endpoints are both public. +snapshot endpoints are both public. Every coordinate is listed in +[Reference Modules and coordinates](../reference/modules-and-coordinates.md). ## Gradle, with the BOM @@ -61,12 +63,14 @@ dependencies { ``` -Use this only if your build cannot import a platform. Versioning the modules by hand lets them drift -into a combination that was never built or tested together. +Use this only if your build cannot import a platform. Versions set by hand have to be kept in step +with each other by you; the reason the BOM exists is in +[Versioning and releases](../contributing/versioning-and-releases.md#why-consumers-take-the-version-from-the-bom). ## Maven -Add both repositories to `pom.xml`, then declare the modules with the same coordinates: +Add both repositories to `pom.xml`, then import the BOM and declare the modules with the same +coordinates: ```xml @@ -85,6 +89,54 @@ Add both repositories to `pom.xml`, then declare the modules with the same coord ``` +Import `falco-bom` in ``, so that the modules it constrains take their version from it: + +```xml + + + + net.onelitefeather + falco-bom + 3.0.1 + pom + import + + + +``` + +Then declare the modules you need. The three modules in the BOM have no version; `falco-migration` takes +its own: + +```xml + + + net.onelitefeather + falco-anvil + + + net.onelitefeather + falco-light + + + net.onelitefeather + falco-instance + + + net.onelitefeather + falco-migration + 3.0.1 + + + net.minestom + minestom + 2026.10.07-26.2 + + +``` + +Minestom is declared last, as in the Gradle steps: Falco does not bring it. + ## Resolving snapshots instead of releases Point at the snapshot endpoint and keep the coordinates unchanged; only the version differs: @@ -95,13 +147,14 @@ repositories { } ``` -Do not build a release of your own against a snapshot — a snapshot version keeps moving as commits -land on `main`. +Do not release a build of your own against a snapshot. The snapshot version keeps moving as commits +land on `main`; see [Reference Modules and coordinates](../reference/modules-and-coordinates.md#snapshot-versions). ## Check it worked `net.onelitefeather.falco.anvil.FalcoAnvilLoader` resolves in your IDE, and a build that only -declares it compiles. If the modules resolved but Minestom types do not, step 3 is missing. +declares it compiles. If the modules resolved but Minestom types do not, the Minestom dependency +(step 3 in Gradle, the last `` in Maven) is missing. ## If it does not work @@ -109,7 +162,8 @@ A 401 from `repo.onelitefeather.dev` means the build is reaching the *internal* the public one — check the URL against [Reference Modules and coordinates](../reference/modules-and-coordinates.md). Consuming Falco never needs credentials; only [How-to Build Falco from source](build-falco-from-source.md) does. -A 404 on `falco-bom` below version `1.0.0` is expected: the BOM did not exist yet. +A 404 on `falco-bom` below version `1.0.0` is expected: the BOM did not exist yet. See +[Reference Supported versions](../reference/supported-versions.md#artefact-availability). See also: [Reference Modules and coordinates](../reference/modules-and-coordinates.md) for every coordinate, endpoint and Javadoc address · [Tutorial Load your first world with Falco](../tutorials/load-your-first-world-with-falco.md) to use what you just declared. diff --git a/docs/how-to/build-falco-from-source.md b/docs/how-to/build-falco-from-source.md index 004feea..182f69a 100644 --- a/docs/how-to/build-falco-from-source.md +++ b/docs/how-to/build-falco-from-source.md @@ -1,10 +1,12 @@ # Build Falco from source -Compile Falco itself, run its tests and render its Javadoc. Needed only to work on Falco. Consuming -the published artefacts needs none of this — that is [How-to Add Falco to your build](add-falco-to-your-build.md). +Compile Falco itself, run its tests and render its Javadoc. You end with a jar per library module and a +benchmark jar. This is needed only to work on Falco. Consuming the published artefacts needs none of +this — that is [How-to Add Falco to your build](add-falco-to-your-build.md). -**Before you start:** Java 25, and OneLiteFeather Maven credentials. Falco compiles against Minestom -and the internal `mycelium-bom`, both served from an authenticated endpoint. +**Before you start:** Java 25, and OneLiteFeather Maven credentials, because Minestom and the internal +`mycelium-bom` are served from an authenticated endpoint. How the build is put together is in +[Build setup](../contributing/build-setup.md). 1. Export the credentials: @@ -40,7 +42,7 @@ and the internal `mycelium-bom`, both served from an authenticated endpoint. ## If it does not work A 401 during dependency resolution means the two environment variables from step 1 are unset or -wrong. This is the failure that actually occurs; it is not a repository outage. +wrong. -See also: [Build Setup](../contributing/build-setup.md) for what the build is made of · +See also: [Build setup](../contributing/build-setup.md) for what the build is made of · [How-to Run the JMH benchmark suite](run-the-jmh-benchmark-suite.md) to run what step 4 produced. diff --git a/docs/how-to/compute-light-for-a-loaded-world.md b/docs/how-to/compute-light-for-a-loaded-world.md index 77f042a..e3b4c47 100644 --- a/docs/how-to/compute-light-for-a-loaded-world.md +++ b/docs/how-to/compute-light-for-a-loaded-world.md @@ -1,8 +1,8 @@ # Compute light for a loaded world Produce block and sky light for chunks that arrived without it — a generated world, or a world whose -stored light you do not trust. Each call below writes its result into the chunk's sections through -`Light#set(byte[])`. +stored light you do not trust. You end with light written into a chunk's sections, through +`Light#set(byte[])`, and with a check that it is right. **Before you start:** `falco-light` on the classpath ([How-to Add Falco to your build](add-falco-to-your-build.md)), and a loaded `Instance`. If your server only loads pre-lit worlds from `.mca`, none of this runs — @@ -11,13 +11,14 @@ loaded `Instance`. If your server only loads pre-lit worlds from `.mca`, none of ## Light one chunk 1. Create a `ChunkLightService`. It keeps no state between calls, so one instance serves as many - threads as you like: + threads as you like. Load the chunk from your `Instance` (`// your Instance`): ```java + import net.minestom.server.instance.Chunk; import net.onelitefeather.falco.light.ChunkLightService; ChunkLightService lighting = new ChunkLightService(); - Chunk chunk = instance.loadChunk(0, 0).join(); + Chunk chunk = instance.loadChunk(0, 0).join(); // your Instance ``` 2. Compute block light and read a level back: @@ -30,66 +31,32 @@ loaded `Instance`. If your server only loads pre-lit worlds from `.mca`, none of This works with **any** chunk, regardless of which loader produced it — the Anvil loader of Falco, the one Minestom ships with, or a generated chunk. A test covers the round trip through -`FalcoAnvilLoader` explicitly. - -**One service is enough for a whole server.** Unlike `LightPropagator` below, `ChunkLightService` -holds exactly one field, the `BlockLightSource` it was constructed with, and every working buffer -lives in a `ChunkLightPropagator` built inside the call. A single instance may therefore be used by -as many threads as you like — a property of the class layout rather than of a benchmark. +`FalcoAnvilLoader` explicitly. Why one service is enough for a whole server is explained in +[Explanation How the concurrency design works](../explanation/how-the-concurrency-design-works.md#one-light-service-serves-every-thread). ### Sky light -```java -lighting.calculateSky(chunk); -``` - -Sky light enters from above and falls straight down **without losing a level** until something stops -it — which is why an open field is fully lit at every height while a cave is dark. Only after the -fall is interrupted does it spread like any other light, losing one level per block. +3. Compute sky light the same way: -### Across chunk borders + ```java + lighting.calculateSky(chunk); + ``` -```java -lighting.calculateWithNeighbours(instance, chunkX, chunkZ); -``` + Sky light falls from above without losing a level until something stops it; the reasoning is in + [Explanation How the light engine works](../explanation/how-the-light-engine-works.md#sky-light). -Lighting a chunk on its own ends its light at the border, which shows up as a straight dark line -every sixteen blocks. This method exchanges the border levels with every already loaded neighbour in -both directions, and **writes only the chunk in the middle**. Neighbours that are not loaded are -skipped rather than forced to load. +### Across chunk borders -One round of that exchange is not enough. A source in the corner of a chunk sends light through two -borders, and the light that entered a neighbour has to leave it again on another side to arrive in -the chunk diagonally behind it. The exchange therefore repeats over the whole area — the chunk and -the eight positions around it — until no chunk of it raises a level any more: +4. Light the chunk together with its loaded neighbours, using the chunk coordinates of the middle chunk: -| Property | How it is reached | -| --- | --- | -| Terminates | An injection only ever raises a level, and a level is capped at fifteen, so the repetition walks towards a fixed point. | -| Same result every time | The area is a fixed-size array walked in a fixed order, not a map. Since every step only raises levels, the fixed point does not depend on the order either. | -| Reads a chunk once | The opacity tables of every participating chunk are built once, before the first round, and reused by all of them. | -| Cannot loop forever | The amount of rounds is capped at sixteen, which is one more than the highest level that can exist. Hitting the cap is reported through `LOGGER.warn` instead of being accepted silently. | - -A radius of one chunk is enough because a level of fifteen cannot survive sixteen blocks of travel, -so nothing the middle chunk emits can reach a second ring. - -**The eight chunks around the middle are read and never written, and that is not a shortcut.** They -only exchanged light inside the 3×3, so whatever they legitimately receive from outside it is missing -from their result; writing that back would replace their correct light with a darker one. The middle -chunk does not have the problem, and provably so: a source outside the 3×3 is at least seventeen -blocks away from it and no path can be shorter than the direct distance, so not even a level of -fifteen survives the trip. Writing one chunk instead of nine is therefore cheaper *and* correct. The -method used to write all nine, which is the defect this replaces. - -**What this method still has over a one-chunk area.** The 3×3 includes the four **diagonal** chunks; -the ring of a [`ChunkLightArea`](keep-chunk-light-up-to-date-automatically.md) is built from face neighbours -only. A source in a diagonal chunk reaches the middle chunk through the chunk between them, and the -neighbourhood carries that — an area of a single chunk never reads it. For one chunk this is -therefore the more accurate of the two calls; for several connected chunks the area is the cheaper -one, because it reads every chunk once instead of once per neighbourhood. That an area misses its own -diagonals is a gap of its own, and it is recorded in [Project Status](../project-record/project-status.md) rather than -glossed over here. + ```java + lighting.calculateWithNeighbours(instance, chunkX, chunkZ); // your Instance and chunk coordinates + ``` + Lighting a chunk on its own leaves a straight dark line every sixteen blocks at its borders. This + method exchanges light with the loaded neighbours and writes only the chunk in the middle. The + repetition, the reason the eight neighbours are never written, and the diagonal chunks it reads are + described in [Explanation How the light engine works](../explanation/how-the-light-engine-works.md#light-across-chunk-borders). ## Choose the right call @@ -127,16 +94,23 @@ int level = light.get(8, 8, 8); byte[] stored = light.toArray(); // empty when the section is dark ``` -`BlockLightSource` can be implemented directly to run the engine without a server: +`BlockLightSource` can be implemented directly to run the engine without a server. The two state ids +below are placeholders for your own: one for a lamp that emits light, one for a stone block that blocks +every face. ```java +import net.onelitefeather.falco.light.BlockFace; +import net.onelitefeather.falco.light.BlockLightSource; + +int LAMP = 1; // your state id of a lamp +int STONE = 2; // your state id of stone + BlockLightSource fake = new BlockLightSource() { @Override public int emission(int stateId) { return stateId == LAMP ? 15 : 0; } @Override public boolean blocksFace(int stateId, BlockFace face) { return stateId == STONE; } }; ``` - Keep **one `LightPropagator` per worker thread**. It holds its level buffer and queue in instance fields, so a shared one produces wrong light rather than an exception. `ChunkLightService` is the exception and is safe to share, because it builds a propagator inside every call. diff --git a/docs/how-to/keep-chunk-light-up-to-date-automatically.md b/docs/how-to/keep-chunk-light-up-to-date-automatically.md index b13c64f..c90442c 100644 --- a/docs/how-to/keep-chunk-light-up-to-date-automatically.md +++ b/docs/how-to/keep-chunk-light-up-to-date-automatically.md @@ -1,125 +1,42 @@ # Keep chunk light up to date automatically Have chunks relight themselves when blocks change, instead of noticing the change and calling the -engine yourself. This is the same entry point Minestom offers with -`setChunkSupplier(LightingChunk::new)`. +engine yourself. You end with an instance whose chunks are lit by a scheduler, and with a block change +that shows up lit on the next tick, without a call of your own. This is the same entry point Minestom +offers with `setChunkSupplier(LightingChunk::new)`. **Before you start:** `falco-light` on the classpath, and an `Instance` whose chunk supplier you can set. ## Set the scheduler's chunk supplier -Every call in [How-to Compute light for a loaded world](compute-light-for-a-loaded-world.md) is one you make yourself: you have to -notice that a chunk changed and decide when to recompute it. `FalcoLightingChunk` removes that step, -and it is the same entry point Minestom sets with `setChunkSupplier(LightingChunk::new)`: - -```java -import net.onelitefeather.falco.light.ChunkLightScheduler; -import net.onelitefeather.falco.light.ChunkLightService; - -ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService()); -instance.setChunkSupplier(scheduler.supplier()); -``` - -That is the whole setup. Keep one scheduler per instance — a second instance handed to the same one -is refused with an `IllegalStateException`, because the dirty set is keyed by chunk coordinates alone -and every instance of a server ticks with the same timestamp. - -Five types, and only one of them holds any behaviour: - -| Type | Responsibility | -| --- | --- | -| `FalcoLightingChunk` | The drop-in, a `DynamicChunk` subclass. Five overrides and no computation: `setBlock` reports the changed position, `onLoad` reports a change of unknown extent, `tick` triggers the pass, `invalidate` drops the cached light packet, and `onLightUpdated` — its half of `LightUpdateAware` — resends it. | -| `ChunkLightScheduler` | Everything else — the dirty set, the once-per-tick trigger, area forming, the executor, back pressure and the staleness rule — so a reader looking for the behaviour finds it in one place. | -| `ChunkArea` | A chunk coordinate pair, and the flood fill that cuts a dirty set into capped connected groups. Pure arithmetic, no Minestom. | -| `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 each chunk it has computed, so the next pass replays the reported changes on it. | -| `LightUpdateAware` | The hook Minestom does not have, so a computed result can be delivered without knowing which chunk type it belongs to. | - - - -**A change marks the 3×3, not just the chunk it happened in.** A lamp on the eastern edge of a chunk -belongs in the light of the chunk east of it, and that chunk would otherwise never be told. The ring -around an area is the mirror image of the same rule: it is read so the edge of the area is right, and -never written, because a ring chunk has not seen what lies on its own far side. - -**A changed block costs a changed block, not nine chunks.** `setBlock` reports the *position* that -changed, not merely that its chunk is dirty. The area keeps the light of every chunk it has computed -and replays the reported positions on it, so a placed torch costs one incremental update rather than -nine full chunk searches. The eight neighbours are still marked, because light crosses borders, but -nothing of theirs is discarded: their own blocks did not move, and what arrives across the border is -derived again by every pass anyway. A change that cannot be placed — a chunk that was generated, -loaded, or written past `setBlock` — is reported as being of unknown extent, and that chunk is -searched again. What this is worth is measured by `IncrementalVsFullBenchmark` and the full table is -in [Measured results](../reference/measured-results.md) under *Measured*: replaying one changed block against -relighting the 3×3 from block states is **2.07× cheaper on block light** (incremental 7 747 ± 402 -against full 16 028 ± 778 µs/op, conservative bounds 1.87× to 2.29×) and **5.60× cheaper on sky -light** (incremental 7 065 ± 438 against full 39 585 ± 1 512 µs/op, bounds 5.07× to 6.20×). Both -pairs of intervals are disjoint, so both are established for that run. The third figure often quoted -alongside them — a tick that pays for both kinds — is **derived**: it is the sum of the two rows -above, not a measurement of its own, and summing assumes such a tick shares no work between the two -kinds, which is an assumption about the implementation rather than a measured fact. Propagating the -errors gives 55 613 ± 2 290 against 14 812 ± 840, so about **3.7×**, bounds 3.41× to 4.14×. - -`IncrementalVsFullBenchmark.full` / `.incremental`, `sky = false` and `sky = true`, one thread, -one fork, 5 warmup and 10 measurement iterations — an override of the class annotation, which -specifies 3 and 5 — **no heap flags**, which is the one class in the harness that pins neither `-Xms` -nor `-Xmx` while building a real Minestom chunk world, JMH 1.37, one 16-core machine recorded as not -idle, no `results.json` committed. One fork: the `±` covers variance between iterations of one JVM, -not between JVM launches — see [What a measurement here means](../explanation/what-a-measurement-here-means.md). - -**The incremental figures predate `ec35f61`, which keeps the opacity tables of a chunk between -passes**, and the `full` figures do not — that path keeps nothing by contract. The factors above -therefore understate what the incremental path now buys, and the third one, a tick paying for both -kinds, is no longer a sum of independent halves: the sky pass reuses the tables the block pass -built. -See [Measured results](../reference/measured-results.md#measured). - -An independent earlier run of the same benchmark gave 1.96× / 6.40× / 3.95×. That is the most useful -sentence available about any number on this page: **the ratios reproduce and the third digits do -not.** Two runs, two JVMs, same direction, same order of magnitude, different last digit — which is -exactly what a single-fork measurement should be expected to deliver, and exactly the reason no third -digit anywhere here is load-bearing. - -**What is kept is the light of a chunk *alone*, before any border exchange**, and that is what makes -the hard direction tractable. Taking light back is the case an incremental engine gets wrong, because -a retraction has to run until light from somewhere else legitimately takes over — and a retraction -that had to leave the chunk to find that point would need the neighbours retracted with it. It never -has to here: the kept light contains nothing from any neighbour, so every level in it originates -inside the chunk and the retraction is complete at the border by construction. The light that crosses -borders is not stored at all; it is derived again on every pass, from a *copy* of the kept light, and -an exchange only ever raises levels and so cannot carry a stale glow forward. - -The kept light is bounded — roughly 100 KB per chunk and kind of light, for at most 128 chunks by -default (`ChunkLightArea.DEFAULT_MAX_CACHED_CHUNKS`), least recently used first — and dropping an -entry costs a full propagation and nothing else. The result is the same bytes a full recalculation -produces, and a chunk that cannot be followed incrementally is simply propagated again. - - - - -**Areas are formed once per tick and capped.** `Chunk#tick(long)` runs per chunk, but a pass has to -see every change of the tick before it groups anything, so the scheduler runs its pass for the first -chunk reporting a timestamp it has not seen. Connected dirty chunks are lit together, and the group -is closed at `maxAreaSize` chunks, 16 by default, with the remainder starting the next area. The cap -is there because every chunk of an area and of its ring is read and turned into opacity tables inside -one tick; a `ChunkLightState` of roughly 100 KB per chunk is the smaller half of that bill. 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. A chunk whose area is still running stays -marked but is not submitted again, and a chunk that changed while its area ran has its result -discarded and stays dirty rather than being written from block states that are already gone. - -## The scheduler's executor is injectable, and a test should inject one - -```java -// Deterministic: the task runs on the calling thread, so a tick is finished when onTick returns. -ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService(), Runnable::run, 16); -``` +1. Every call in [How-to Compute light for a loaded world](compute-light-for-a-loaded-world.md) is one you make yourself: you have to + notice that a chunk changed and decide when to recompute it. `FalcoLightingChunk` removes that step, + and it is the same entry point Minestom sets with `setChunkSupplier(LightingChunk::new)`. Create a + scheduler and set its supplier on your `Instance` (`// your Instance`): + + ```java + import net.onelitefeather.falco.light.ChunkLightScheduler; + import net.onelitefeather.falco.light.ChunkLightService; + + ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService()); + instance.setChunkSupplier(scheduler.supplier()); // your Instance + ``` + +2. That is the whole setup. Keep one scheduler per instance: a second instance handed to the same one + is refused with an `IllegalStateException`. A server with many worlds needs one scheduler per world. + The five types the engine is made of, and what each one is responsible for, are listed in + [Reference API surface and stability](../reference/api-surface-and-stability.md#light-engine-types). + +## Choose the executor in a test + +1. Inject a direct executor when a test needs a deterministic result. The task then runs on the calling + thread, so a tick is finished when `onTick` returns: + + ```java + // Deterministic: the task runs on the calling thread, so a tick is finished when onTick returns. + ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService(), Runnable::run, 16); + ``` 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. The bound sits inside the task rather @@ -130,78 +47,26 @@ A direct executor turns the whole cycle synchronous, which is what makes the tes deterministic rather than timing-dependent — place a block, call `tick`, assert the light. A server that already has a pool can hand that one over instead. -**A batch is covered by this too.** `AbsoluteBlockBatch#apply` ends by calling `sendLighting()` on -every touched chunk that is a `LightingChunk` and skips every other type, so a `FalcoLightingChunk` -would never be resent by it. It does not have to be: a batch writes through `setBlock`, which marks -the chunk here, so the next tick lights the whole touched region and sends it. The result arrives one -tick later than Minestom's would, and it arrives for the ring around the batch as well, which -Minestom's path does not manage. - -## Incremental updates - -`ChunkLightService#calculate` always recomputes the whole chunk. For a single block change that is -wasteful, and `ChunkLightState` exists for that case. It is what `ChunkLightScheduler` runs on: -`FalcoLightingChunk#setBlock` reports the position, `ChunkLightArea` keeps the light of the chunk and -replays the position on it. Use the type directly only if you drive the engine yourself. - -```java -ChunkLightState state = ChunkLightState.blockLight(opacityTables); - -// after a block changed at that position -state.update(updatedOpacityTables, x, y, z); -List light = state.toSections(); -``` - -Adding brightness is straightforward — it only spreads. **Removing** it is the hard case and the -reason this class exists: when a light source disappears, the brightness it had spread is still -stored in every block around it, and spreading again would keep that glow forever. The update -therefore runs two passes. The first retracts every level that originated from the changed position -and collects the still valid levels it meets at the edge of the retracted area; the second spreads -those back in. - -Which way an update goes, drawn out: - -```mermaid -flowchart TB - C["a block changed at x, y, z"] --> K{"is the position
brighter or darker than before?"} - K -->|brighter| S["one pass: spread outwards.
Levels only ever rise, so nothing
has to be taken back"] - K -->|darker| R1["pass 1: retract.
Walk outwards and clear every level
that came from this position"] - R1 --> R2["at the edge of the cleared area,
collect the levels that came
from somewhere else"] - R2 --> R3["pass 2: spread those collected
levels back in"] - S --> D["result is identical to
a full recalculation"] - R3 --> D -``` - -The second pass is not a correction of the first. The light of every *other* source in the -neighbourhood is legitimate and was cleared along with the rest simply because it stood in the way; -collecting it at the edge and letting it back in is what puts it back. Skipping the retraction -instead and only spreading again would leave the removed source's glow in place for good. - -`ChunkLightStateTest#testTheIncrementalResultMatchesAFullRecalculation` asserts that the incremental -result is identical to a full recalculation, block for block. - -## Incremental sky light updates - -Sky light has an origin no block holds: it falls in from above. An update can therefore not tell -from the levels alone which positions lost their origin and which gained one, and a state that holds -sky light keeps a heightmap for that reason — the highest position that stops the sky, per column. - -A block change moves exactly one column of that heightmap, and the difference between the old and -the new height names the positions whose origin changed: - -| Change | Effect on the column | -| --- | --- | -| A block is placed above the current height | Everything between the old and the new height falls out of the open sky and gives its level back. What is left is refilled from the sides, which is why a single pillar leaves a level of fourteen below it rather than darkness. | -| The highest blocking block is removed | The column opens down to the next block below, and every position in between receives the full level again and spreads it. | -| The change is below the height | The height stays where it is. The changed position is retracted and refilled from its neighbours, exactly as a block light update works. | - -Only the changed column is walked again, so an update no longer re-seeds all two hundred and fifty -six columns of the chunk. - -`SkyLightUpdateTest` asserts the result against a full recalculation block for block, for both -directions, for a change that is not in the highest blocking position, and for a seeded sequence of -random changes that verifies the equality after every single one of them. +## Replay a single block change by hand + +1. Use the incremental state directly only if you drive the engine yourself. `ChunkLightState` is what the + scheduler runs on, and it keeps the light of a chunk between changes: + + ```java + import java.util.List; + import net.onelitefeather.falco.light.ChunkLightState; + import net.onelitefeather.falco.light.LightNibbles; + + ChunkLightState state = ChunkLightState.blockLight(opacityTables); // your List + + // after a block changed at that position + state.update(updatedOpacityTables, x, y, z); // your List after the change + List light = state.toSections(); + ``` + The scheduler reports the changed position; `ChunkLightState#update` replays it, and a removed light + source is retracted before the light is spread again. How this works, and why the update is identical to a + full recalculation, is explained in [Explanation How the light engine works](../explanation/how-the-light-engine-works.md#incremental-updates-and-what-they-keep). ## Check it worked diff --git a/docs/how-to/load-an-anvil-world.md b/docs/how-to/load-an-anvil-world.md index 1afce06..e5a5ee0 100644 --- a/docs/how-to/load-an-anvil-world.md +++ b/docs/how-to/load-an-anvil-world.md @@ -1,7 +1,8 @@ # Load an Anvil world Serve a stored Anvil world (`r...mca` region files) from a Minestom instance using -`FalcoAnvilLoader` instead of the built-in `AnvilLoader`. +`FalcoAnvilLoader` instead of the built-in `AnvilLoader`. You end with an instance that streams its chunks +from the world directory and refuses to replace a chunk it cannot read. **Before you start:** `falco-anvil` on the classpath ([How-to Add Falco to your build](add-falco-to-your-build.md)), and a world directory whose chunks are at or above the version floor on @@ -48,15 +49,6 @@ public final class Bootstrap { ## Hand the loader to a map provider instead - - `FalcoAnvilLoader` is a plain `net.minestom.server.instance.ChunkLoader`, so anything that already accepts one takes it without knowing this library exists. Pass it to `InstanceManager#createInstanceContainer(RegistryKey, ChunkLoader)`: @@ -83,16 +75,11 @@ public final class MapLoaders { } ``` -The relevant Falco signatures are: - -| Member | Declaration | -| --- | --- | -| [`FalcoAnvilLoader(Path, Key)`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java) | `public FalcoAnvilLoader(Path worldRoot, Key dimension)` | -| [`FalcoAnvilLoader(Path, Key, int)`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java) | `public FalcoAnvilLoader(Path worldRoot, Key dimension, int openRegionLimit)` | - A provider that keeps a world root per map passes that root and the dimension key of the instance it -is building. Any other `ChunkLoader` is supplied the same way, which is what makes the choice -reversible. +is building. The constructors it can call are listed in +[Reference API surface and stability](../reference/api-surface-and-stability.md#entry-points-and-signatures), +and why this choice is reversible is explained in +[Explanation Choosing between Falco and the built-in loader](../explanation/choosing-between-falco-and-the-built-in-loader.md). The `falco-demo` module does exactly this and is compiled on every build: [`LoaderKind#create`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-demo/src/main/java/net/onelitefeather/falco/demo/LoaderKind.java) @@ -102,8 +89,8 @@ hands the result to `createInstanceContainer`. ## Raise the cached-region limit -The number of region files held in the cache defaults to 64 and is set through the three-argument -constructor: +The three-argument constructor sets how many region files stay open. The default is in +[Reference API surface and stability](../reference/api-surface-and-stability.md#entry-points-and-signatures). ```java FalcoAnvilLoader loader = new FalcoAnvilLoader(worldRoot, dimension, 256); diff --git a/docs/how-to/migrate-a-world-from-an-older-version.md b/docs/how-to/migrate-a-world-from-an-older-version.md index 7fc868c..ec4d313 100644 --- a/docs/how-to/migrate-a-world-from-an-older-version.md +++ b/docs/how-to/migrate-a-world-from-an-older-version.md @@ -1,44 +1,66 @@ # Migrate a world from an older version Lift a world written by an older server to the version you run, so that blocks renamed since do not -quietly become air. +quietly become air. You end with a loader that translates the outdated chunks it reads, either in memory +or written back to the world. **Before you start:** `falco-migration` on the classpath ([How-to Add Falco to your build](add-falco-to-your-build.md)), a -world at `DataVersion` `1519` or above, and disk space for a backup — it cannot be switched off. +world at `DataVersion` `1519` or above (the floor is in [Reference Migration modes](../reference/migration-modes.md#floors)), +and disk space for a backup. The backup is taken before a region file is first written, and it cannot be +switched off in `ON_DISK` mode. ## Translate on read, leaving the world untouched -```java -FalcoAnvilLoader.builder() - .migration(ChunkMigrationMode.IN_MEMORY) - .build(worldRoot, dimension); -``` +1. Select `IN_MEMORY` on the builder. The world root and dimension are the ones from + [How-to Load an Anvil world](load-an-anvil-world.md): -The world on disk stays readable by the older server it came from, and a wrong rule cannot damage -anything permanently. The translation is paid again on every load of every outdated chunk. + ```java + import net.kyori.adventure.key.Key; + import net.onelitefeather.falco.anvil.ChunkMigrationMode; + import net.onelitefeather.falco.anvil.FalcoAnvilLoader; + + import java.nio.file.Path; + + Path worldRoot = Path.of("worlds", "lobby"); + Key dimension = Key.key("minecraft", "overworld"); + + FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .migration(ChunkMigrationMode.IN_MEMORY) + .build(worldRoot, dimension); + ``` + + The world on disk stays readable by the older server it came from. The translation is paid again on + every load of every outdated chunk; the reasons are in + [Explanation How world migration works](../explanation/how-world-migration-works.md#the-two-halves). ## Translate once and write it back -```java -FalcoAnvilLoader.builder() - .migration(ChunkMigrationMode.ON_DISK) - .build(worldRoot, dimension); -``` +2. Select `ON_DISK` instead, when you want each chunk translated once and never again. The same + builder, with the mode changed: + + ```java + FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .migration(ChunkMigrationMode.ON_DISK) + .build(worldRoot, dimension); + ``` -Each chunk is translated once and then never again. **This rewrites the world.** Afterwards its -chunks carry the running server's data version and the older server can no longer read them. + **This rewrites the world.** Afterwards its chunks carry the running server's data version and the + older server can no longer read them. ## Put the backup somewhere else -```java -FalcoAnvilLoader.builder() - .migration(ChunkMigrationMode.ON_DISK) - .migrationBackup(Path.of("/backups", "lobby")) - .build(worldRoot, dimension); -``` +3. Optionally, move the backup out of the world directory. Nothing removes the backup; this slot only + moves it: + + ```java + FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .migration(ChunkMigrationMode.ON_DISK) + .migrationBackup(Path.of("/backups", "lobby")) + .build(worldRoot, dimension); + ``` -Nothing removes the backup — this slot only moves it. The default is -`/falco-migration-backup/`, beside the region directory and never inside it. + The default is `/falco-migration-backup/`, beside the region directory and never + inside it. The path rules are in [Reference Migration modes](../reference/migration-modes.md#backup). ## Check it worked @@ -49,10 +71,12 @@ region file that was written. ## If it does not work A loader that **fails to build** after `migration(...)` means no `ChunkMigrator` is on the classpath; -add `falco-migration`. A chunk that is still declined after migration is below `DataVersion` `1519` -— the engine does not attempt those. +add `falco-migration`. A chunk that is still declined after migration is below `DataVersion` `1519`, +the floor described in [Reference Migration modes](../reference/migration-modes.md#floors): the engine +does not attempt those. -A block that still becomes air had no rule. Migration only fixes renames someone has written down. +A block that still becomes air had no rule. Migration only fixes renames someone has written down; see +[Explanation How world migration works](../explanation/how-world-migration-works.md#the-rules-and-where-their-numbers-come-from). See also: [Reference Migration modes](../reference/migration-modes.md) for every mode, slot and floor · [Explanation How world migration works](../explanation/how-world-migration-works.md) for why the backup is mandatory · diff --git a/docs/how-to/replace-the-version-and-unknown-entry-policies.md b/docs/how-to/replace-the-version-and-unknown-entry-policies.md index 4707f1c..d9b4293 100644 --- a/docs/how-to/replace-the-version-and-unknown-entry-policies.md +++ b/docs/how-to/replace-the-version-and-unknown-entry-policies.md @@ -2,20 +2,31 @@ Swap out the check that decides whether a chunk is readable, or the substitution applied to an unknown block or biome — either by handing the loader an instance directly or by registering one on -the classpath. +the classpath. You end with a loader whose opening log line names the policy it actually resolved. **Before you start:** `falco-anvil` on the classpath, and a loader built through `FalcoAnvilLoader.builder()`. A loader built through the two-argument constructor uses classpath -discovery for both policies, which is what keeps its behaviour unchanged. +discovery for both policies, which is what keeps its behaviour unchanged. The four policy classes and the +resolution rules are in [Reference Replaceable policies](../reference/replaceable-policies.md). ## Set a policy explicitly -```java -FalcoAnvilLoader loader = FalcoAnvilLoader.builder() - .versionPolicy(myVersionPolicy) - .unknownEntryPolicy(myUnknownEntryPolicy) - .build(Path.of("worlds", "lobby"), dimension); -``` +1. Pass your own instances to the builder. Here `Path.of("worlds", "lobby")` and the dimension key are the + ones from [How-to Load an Anvil world](load-an-anvil-world.md): + + ```java + import net.kyori.adventure.key.Key; + import net.onelitefeather.falco.anvil.FalcoAnvilLoader; + + import java.nio.file.Path; + + Key dimension = Key.key("minecraft", "overworld"); + + FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .versionPolicy(myVersionPolicy) // your ChunkVersionPolicy + .unknownEntryPolicy(myUnknownEntryPolicy) // your UnknownEntryPolicy + .build(Path.of("worlds", "lobby"), dimension); + ``` Setting a slot turns classpath discovery off for that policy. Setting both the slot and discovery for the same policy throws `IllegalStateException`. @@ -27,29 +38,33 @@ for the same policy throws `IllegalStateException`. 3. Leave the builder slot untouched, or call `discoverVersionPolicy()` / `discoverUnknownEntryPolicy()` explicitly. -Falco's own default steps aside for a single foreign provider. Registering **two** foreign providers -for the same service throws at build time, naming both classes. +How the shipped default and foreign providers interact, including the build-time error for two foreign +providers, is listed in [Reference Replaceable policies](../reference/replaceable-policies.md#resolution-rules). ## Change only the version floor -Keep the shipped policy and move the number it compares against: +1. Keep the shipped policy and move the number it compares against. The two slots are independent: one + is what a load has to clear, the other is what a save claims, as + [Reference Supported versions](../reference/supported-versions.md#chunk-data-version-floor) lists them. -```java -FalcoAnvilLoader loader = FalcoAnvilLoader.builder() - .minimumDataVersion(FalcoAnvilLoader.DEFAULT_MINIMUM_DATA_VERSION) // read: reject below this - .dataVersion(4189) // write: stamp this - .build(Path.of("worlds", "lobby"), dimension); -``` + ```java + FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .minimumDataVersion(FalcoAnvilLoader.DEFAULT_MINIMUM_DATA_VERSION) // read: reject below this + .dataVersion(4189) // write: stamp this, sample value + .build(Path.of("worlds", "lobby"), dimension); + ``` -The two slots are unrelated: one is what a load has to clear, the other is what a save claims. + `4189` is a sample value taken from the test suite, not a constant of the library. ## Turn the version guard off -```java -FalcoAnvilLoader loader = FalcoAnvilLoader.builder() - .versionPolicy(null) - .build(Path.of("worlds", "lobby"), dimension); -``` +1. Remove the version policy: + + ```java + FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .versionPolicy(null) + .build(Path.of("worlds", "lobby"), dimension); + ``` **Losing the guard has a cost.** A loader with no version policy reads a pre-`21w43a` world as air again, with no error and no log line — the failure the guard exists to end. Read diff --git a/docs/how-to/reproduce-a-published-measurement.md b/docs/how-to/reproduce-a-published-measurement.md index 64fc485..538b91b 100644 --- a/docs/how-to/reproduce-a-published-measurement.md +++ b/docs/how-to/reproduce-a-published-measurement.md @@ -1,56 +1,72 @@ # Reproduce a published measurement -Re-run one of the tables on [Reference Measured results](../reference/measured-results.md) on your own machine and compare. This is +Re-run one of the tables on [Reference Measured results](../reference/measured-results.md) on your own machine and compare. You end +with one result file per published table, recorded together with a description of your machine. This is the procedure a third party uses to check a published claim. **Before you start:** the benchmark jar ([How-to Run the JMH benchmark suite](run-the-jmh-benchmark-suite.md)). Read [Explanation What the benchmarks establish](../explanation/what-the-benchmarks-establish.md) first — the threats to validity there decide what your -result means. - -Everything above can be re-performed. It cannot yet be reproduced exactly, because the environment of -the original runs was not recorded — that gap is listed below rather than papered over. What follows -is enough for a third party to run the same configurations and say whether their machine agrees. - -Build the jar once: - -```bash -./gradlew :falco-benchmarks:jmhJar -java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar -l # list every benchmark -``` - -Then, one command per published table. These are the configurations the tables above state; where a -table's run settings were not recorded, the class annotation is used and the command says so. - -```bash -# Loader under contention. One run per thread count -- -t takes a single value, so this is four -# separate runs, and the four rows of that table were produced that way. -for t in 1 2 4 8; do - java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ - "RegionFileComparisonBenchmark.(falco|minestom)(Read|Write)" \ - -p distinctStates=200 -f 1 -wi 3 -i 5 -t "$t" \ - -rf json -rff "loader-contention-t$t.json" -done - -# The two-fork control for the four-thread row. This is the run the loader's claim rests on. -java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ - "RegionFileComparisonBenchmark.(falco|minestom)Read" \ - -p distinctStates=200 -f 2 -wi 5 -i 10 -t 4 -rf json -rff loader-contention-2fork.json - -# Light engine, equal source brightness. The six-row table. -java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ - "LightEngineComparisonBenchmark.(falco|minestom)" \ - -p emissionMix=UNIFORM -f 1 -wi 5 -i 10 -rf json -rff light-uniform.json - -# Mixed source brightness. lightSources=1 is skipped because MIXED degenerates to UNIFORM there. -java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ - "LightEngineComparisonBenchmark.(falco|minestom)" \ - -p emissionMix=MIXED -p lightSources=8,64 -f 1 -wi 3 -i 5 -rf json -rff light-mixed.json - -# Where a save spends its time. Annotation settings; the original run's command line is unrecorded. -java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ - ChunkSaveStageBenchmark -p distinctStates=200 -f 1 -wi 5 -i 5 \ - -rf json -rff save-stages.json -``` +result means. What the published tables can and cannot be compared against is listed in +[Reference Measured results](../reference/measured-results.md#measurement-environment). + +## Build the jar and list the benchmarks + +1. Build the jar once and list every benchmark: + + ```bash + ./gradlew :falco-benchmarks:jmhJar + java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar -l # list every benchmark + ``` + +## Run the loader table + +2. Run the loader under contention. This is one run per thread count — `-t` takes a single value, so the + four rows of that table were produced by four separate runs: + + ```bash + for t in 1 2 4 8; do + java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ + "RegionFileComparisonBenchmark.(falco|minestom)(Read|Write)" \ + -p distinctStates=200 -f 1 -wi 3 -i 5 -t "$t" \ + -rf json -rff "loader-contention-t$t.json" + done + ``` + +3. Run the two-fork control for the four-thread row. This is the run the loader's claim rests on: + + ```bash + java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ + "RegionFileComparisonBenchmark.(falco|minestom)Read" \ + -p distinctStates=200 -f 2 -wi 5 -i 10 -t 4 -rf json -rff loader-contention-2fork.json + ``` + +## Run the light tables + +4. Run the light engine at equal source brightness. This is the six-row table: + + ```bash + java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ + "LightEngineComparisonBenchmark.(falco|minestom)" \ + -p emissionMix=UNIFORM -f 1 -wi 5 -i 10 -rf json -rff light-uniform.json + ``` + +5. Run the light engine at mixed source brightness. `lightSources=1` is skipped because `MIXED` degenerates + to `UNIFORM` there: + + ```bash + java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ + "LightEngineComparisonBenchmark.(falco|minestom)" \ + -p emissionMix=MIXED -p lightSources=8,64 -f 1 -wi 3 -i 5 -rf json -rff light-mixed.json + ``` + +6. Run the save stages, which show where a save spends its time. The annotation settings are used here; the + original run's command line is unrecorded: + + ```bash + java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \ + ChunkSaveStageBenchmark -p distinctStates=200 -f 1 -wi 5 -i 5 \ + -rf json -rff save-stages.json + ``` `-rf json -rff ` is what makes a run citable: JMH writes the JDK build, the VM name and version, the JVM arguments, every parameter and every iteration into that file. @@ -61,53 +77,37 @@ If you want your numbers to be worth more than ours, raise the fork count. `-f 5 benchmark costs five times the wall clock and produces the only kind of interval that covers run-to-run variance at all. Add `-prof gc` to anything you intend to quote as an allocation. -## What to record about your machine - -None of this is filled in for the published tables, and no value below may be guessed at from the -outside. A replication that records it is more useful than the original. - -| | | -| --- | --- | -| CPU | model, stepping, physical cores, SMT on or off | -| Frequency policy | turbo on or off, governor or P-state driver | -| RAM | size and speed | -| Storage and file system | both region benchmarks touch it | -| OS and kernel | distribution and version | -| JVM | vendor, full version string, GC, all flags | -| Machine state | idle or not, and what else was running | -| Commit | the Falco commit, and the Minestom version `mycelium-bom` resolved | -| Run date | per table | -| Raw results | the `results.json` of every run above | - - - -## What agreement would look like - -A replication is not expected to reproduce these numbers. It is expected to reproduce these -conclusions, and the criteria are stated in advance so that neither side gets to decide after the -fact. - -- **A row marked *supported* agrees** if the direction is the same and your conservative ratio bounds - overlap the ones printed in the table. It **disagrees** if the direction reverses, or if your - bounds are disjoint from ours — which would mean one of the two machines is measuring something the - other is not. -- **A row marked *no difference resolvable* agrees** if your intervals also overlap, and is a *new* - result if yours are disjoint. Resolving a difference we could not is an improvement on this page, - not a contradiction of it. -- **A row marked *not usable* agrees** if your slower side is also dispersed — an error bar that is a - large fraction of its own mean. A run in which Minestom's read time stays tight under four or eight - concurrent readers would be a genuine refutation of the loader's central claim, and it is the - single most valuable measurement anyone outside this project could contribute. -- **Absolute microseconds are not expected to agree at all.** Do not report a mismatch in them as a - failure to reproduce; report the ratio. - -Ratios reproduce, third digits do not. Two of the five repeats this project has run behaved that -way; on the other two, both on the loader table, Falco's numbers reproduced and Minestom's did not. -A fifth that behaves differently from any of them is worth hearing about. - +## Record your machine + +7. Fill in the table below for your run, and keep the `results.json` of every run above. None of this is + filled in for the published tables, and no value below may be guessed at from the outside. A + replication that records it is more useful than the original. Leave a field as TBD rather than guessing; + a wrong value is worse than a missing one. + + | | | + | --- | --- | + | CPU | model, stepping, physical cores, SMT on or off | + | Frequency policy | turbo on or off, governor or P-state driver | + | RAM | size and speed | + | Storage and file system | both region benchmarks touch it | + | OS and kernel | distribution and version | + | JVM | vendor, full version string, GC, all flags | + | Machine state | idle or not, and what else was running | + | Commit | the Falco commit, and the Minestom version `mycelium-bom` resolved | + | Run date | per table | + | Raw results | the `results.json` of every run above | + +## Compare with the published table + +8. Compare your ratios with the published ones. Absolute microseconds are not expected to agree. The + criteria for *supported*, *no difference resolvable* and *not usable* rows, and what a disagreement means, + are stated in advance in + [Explanation What a measurement here means](../explanation/what-a-measurement-here-means.md#what-agreement-would-look-like). + +## Check it worked + +Each run wrote its `results.json`, and the summary of each run has a `±` column. A run without one was not a +measurement, as [How-to Run the JMH benchmark suite](run-the-jmh-benchmark-suite.md#check-it-worked) says. See also: [Reference Measured results](../reference/measured-results.md) for the table you are reproducing and its provenance · [Explanation What a measurement here means](../explanation/what-a-measurement-here-means.md) for what the `±` bounds and what it does not diff --git a/docs/how-to/run-the-jmh-benchmark-suite.md b/docs/how-to/run-the-jmh-benchmark-suite.md index 36f47c8..c0cb3ec 100644 --- a/docs/how-to/run-the-jmh-benchmark-suite.md +++ b/docs/how-to/run-the-jmh-benchmark-suite.md @@ -1,121 +1,94 @@ # Run the JMH benchmark suite -Execute the benchmarks in `falco-benchmarks` on your own machine. A normal `build` never runs them. +Execute the benchmarks in `falco-benchmarks` on your own machine. You end with JMH's summary table and a +`results.json` for the benchmarks you ran. A normal `build` never runs them. **Before you start:** a working build ([How-to Build Falco from source](build-falco-from-source.md)). What the results can and cannot mean is [Explanation What the benchmarks establish](../explanation/what-the-benchmarks-establish.md) — read it before quoting a number. -```bash -./gradlew jmh # every benchmark, full settings -``` +## Run the full suite -That is every measured method for every combination of the parameters its state class declares — 50 -methods over 488 configurations at commit `ca79507`, at the forks and iterations the annotations ask -for. Budget well over an hour and do not touch the machine while it runs. The count is dominated by -`ScalingBenchmark`, whose fifteen section counts and five distinct-state counts share one state class -and therefore form a full cross product of 300 configurations on their own. +1. Run every benchmark with the full settings: -## Or from CI, when the run record matters more than the machine - -The **Benchmark** workflow -([`.github/workflows/benchmark.yml`](https://github.com/OneLiteFeatherNET/Falco/blob/main/.github/workflows/benchmark.yml)) -runs the same jar on demand and uploads four things: `results.json`, the readable output, **the exact -command line as executed**, and a filled-in provenance line. Two profiles: - -| | What it does | -| --- | --- | -| `custom` | one JMH filter in one job — for re-running a single table | -| `full` | the whole suite across sixteen shards, about half an hour where a sequential run is five | - -**Its purpose is provenance, not precision.** Many of the gaps listed on this page are of the form -*no run record was kept* rather than *the number is wrong*, and those close with a run that documents -itself even from a machine that measures worse. Leave `forks` empty to keep each class annotation, -which is what re-running a published table needs. + ```bash + ./gradlew jmh # every benchmark, full settings + ``` -**What its numbers may not be used for.** A shared runner is not the machine the published tables -came from, and the difference is not only in absolute terms: the same light comparison measures 1.35× -there against 1.13× here. A ratio is no more portable than an absolute figure, only more stable when -the hardware is fixed — see -[What a measurement here means](../explanation/what-a-measurement-here-means.md#the-limits-stated-without-softening). CI figures -therefore belong in their own table with their own provenance line, never merged into one measured -elsewhere. + How many methods and configurations that is, and how long it takes, is in + [Reference Benchmark catalogue](../reference/benchmark-catalogue.md#size-of-the-full-run). -One thing the workflow enforces rather than advises: **a shard is a whole class.** Shards run on -separate runners, so splitting a comparison class would put `falcoRead` and `minestomRead` on -different CPUs and the resulting ratio would measure the hardware. That happened once, at 2.13× -against the 1.13× one machine gives. - -The full run is not what you want during development. Restrict it to what you are working on: - -```bash -# One class -./gradlew jmh -Pjmh.include='BitPackerBenchmark' +## Or from CI, when the run record matters more than the machine -# One method -./gradlew jmh -Pjmh.include='ChunkSaveStageBenchmark.codec' +2. Use the **Benchmark** workflow when the run record matters more than the machine. It runs the same jar on + demand and uploads the results with the exact command line. Choose the `custom` profile for one table or + `full` for the whole suite, and leave `forks` empty to keep each class annotation. Its profiles, its + provenance purpose and the rule that a shard is a whole class are in + [Contributing Benchmarks and demo](../contributing/benchmarks-and-demo.md#the-ci-benchmark-workflow). -# A regex over several -./gradlew jmh -Pjmh.include='light\..*Propagator.*' -``` + CI figures come from a shared runner and may not be merged with the published tables. The reason is in + [Explanation What a measurement here means](../explanation/what-a-measurement-here-means.md#the-limits-stated-without-softening). -The Gradle task writes two files: +## Restrict the run to what you are working on -| File | Content | -| --- | --- | -| `build/reports/jmh/human.txt` | the console output | -| `build/reports/jmh/results.json` | machine readable, for [JMH Visualizer](https://jmh.morethan.io/) | +3. The full run is not what you want during development. Restrict it to one class, one method or a regex: -Both land under `build/`, which is gitignored. **No run's output is committed in this repository**, -which is why every provenance line above says so. + ```bash + # One class + ./gradlew jmh -Pjmh.include='BitPackerBenchmark' -## Running the jar directly + # One method + ./gradlew jmh -Pjmh.include='ChunkSaveStageBenchmark.codec' -The task builds a self-contained benchmark jar, which is the faster way to iterate because it skips -Gradle entirely and accepts every JMH option: + # A regex over several + ./gradlew jmh -Pjmh.include='light\..*Propagator.*' + ``` -```bash -./gradlew jmhJar -java -jar build/libs/falco-*-jmh.jar 'BitPackerBenchmark.pack' -f 1 -wi 1 -i 1 +4. Read the two files the Gradle task writes. Both land under `build/`, which is gitignored: -java -jar build/libs/falco-*-jmh.jar -l # list every benchmark -java -jar build/libs/falco-*-jmh.jar -h # every option -``` + | File | Content | + | --- | --- | + | `build/reports/jmh/human.txt` | the console output | + | `build/reports/jmh/results.json` | machine readable, for [JMH Visualizer](https://jmh.morethan.io/) | -A quick smoke run — one fork, one warmup iteration, one measurement iteration — is `-f 1 -wi 1 -i 1`. -That is enough to prove a benchmark executes and produces a plausible number. It is **not** enough -to compare two versions of the code; for that, drop the overrides and let the annotations decide. + No run's output is committed in this repository, which is why every provenance line in the published + tables says so. -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. +## Run the jar directly -## Profiling a benchmark +5. The task builds a self-contained benchmark jar, which is the faster way to iterate because it skips + Gradle entirely and accepts every JMH option: -```bash -java -jar build/libs/falco-*-jmh.jar 'PaletteDataBenchmark.encode' -prof gc -java -jar build/libs/falco-*-jmh.jar 'PaletteDataBenchmark.encode' -prof perfasm # Linux, needs perf -``` + ```bash + ./gradlew jmhJar + java -jar build/libs/falco-*-jmh.jar 'BitPackerBenchmark.pack' -f 1 -wi 1 -i 1 -`-prof gc` is the one worth reaching for first. Several of the claims on the other pages are about -*allocation*, not about time, and `gc.alloc.rate.norm` answers those directly — and does so far more -reproducibly than any timing here. + java -jar build/libs/falco-*-jmh.jar -l # list every benchmark + java -jar build/libs/falco-*-jmh.jar -h # every option + ``` + A quick smoke run — one fork, one warmup iteration, one measurement iteration — is `-f 1 -wi 1 -i 1`. + That is enough to prove a benchmark executes and produces a plausible number. It is **not** enough + to compare two versions of the code; for that, drop the overrides and let the annotations decide. -## Why `build` does not run them + 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. -The benchmarks do not run during `./gradlew build`, `check` or `test`. `jmh` and `jmhJar` are only -reachable when asked for by name. A benchmark run takes long enough that wiring it into the normal -build would make every commit painful, and JMH numbers are too noisy on a shared CI runner to gate -anything on them anyway. +## Profile a benchmark -Verify it for yourself: +6. Profile allocation first. `-prof gc` is the one worth reaching for first, and why it answers allocation + claims more reliably than timing is explained in + [Explanation What the benchmarks establish](../explanation/what-the-benchmarks-establish.md#threats-to-validity). -```bash -./gradlew clean build --dry-run | grep -i jmh # prints nothing except the compile task -``` + ```bash + java -jar build/libs/falco-*-jmh.jar 'PaletteDataBenchmark.encode' -prof gc + java -jar build/libs/falco-*-jmh.jar 'PaletteDataBenchmark.encode' -prof perfasm # Linux, needs perf + ``` -`compileJmhJava` *is* part of `build`, and that is on purpose: a benchmark that no longer compiles -after a refactoring should break the build like any other source set. +## Why build does not run them +The benchmarks are not part of `./gradlew build`, `check` or `test`; they run only when asked for by name, and +`compileJmhJava` is part of `build` on purpose. The build wiring is in +[Contributing Benchmarks and demo](../contributing/benchmarks-and-demo.md#falco-benchmarks). ## Check it worked diff --git a/docs/how-to/use-falcoinstance-instead-of-instancecontainer.md b/docs/how-to/use-falcoinstance-instead-of-instancecontainer.md index 1162131..eac7119 100644 --- a/docs/how-to/use-falcoinstance-instead-of-instancecontainer.md +++ b/docs/how-to/use-falcoinstance-instead-of-instancecontainer.md @@ -1,177 +1,161 @@ # Use FalcoInstance instead of InstanceContainer Build an instance that unloads the chunks it loaded when it is unregistered, and whose chunks -allocate their sections on demand. +allocate their sections on demand. You end with a `FalcoInstance` registered with the server, and +with the steps for the loader, the light engine and the listeners that you need. **Before you start:** `falco-instance` on the classpath ([How-to Add Falco to your build](add-falco-to-your-build.md)). Keep using `InstanceContainer` if you need `SharedInstance` views onto this world, or if you rely on any of the four Minestom sites that branch on `instanceof InstanceContainer` — see [Explanation Why falco-instance exists](../explanation/why-falco-instance-exists.md). -## The shortest form +## Create the shortest form -```java -FalcoInstance instance = FalcoInstance.builder(DimensionType.OVERWORLD) - .register(MinecraftServer.getInstanceManager()); -``` +1. Build the instance and register it in one call: -`register` builds the instance and registers it, in that order, and returns it. The builder is -immutable — every method returns a new one — so a half-configured builder can be handed around -without anybody changing it underneath. + ```java + import net.minestom.server.MinecraftServer; + import net.minestom.server.world.DimensionType; + import net.onelitefeather.falco.instance.FalcoInstance; -The constructors are public as well if a builder is not what you want: -`new FalcoInstance(UUID.randomUUID(), DimensionType.OVERWORLD)`. + FalcoInstance instance = FalcoInstance.builder(DimensionType.OVERWORLD) + .register(MinecraftServer.getInstanceManager()); + ``` -## With a chunk loader + `register` builds the instance and registers it, in that order, and returns it. The constructors are + public as well; their signatures are in + [Reference API surface and stability](../reference/api-surface-and-stability.md#entry-points-and-signatures). -```java -FalcoAnvilLoader loader = new FalcoAnvilLoader(Path.of("worlds", "lobby"), DimensionType.OVERWORLD.key()); +## Add a chunk loader and shut down cleanly -FalcoInstance instance = FalcoInstance.builder(DimensionType.OVERWORLD) - .chunkLoader(loader) - .autoChunkLoad(true) - .ownsLoader(true) - .saveOnShutdown(true) - .registerAndShutdownWith(MinecraftServer.getInstanceManager(), - MinecraftServer.getSchedulerManager()); -``` +2. Give the instance a loader, and let it own the loader and save on shutdown: -`registerAndShutdownWith` is the form worth preferring when the instance owns its loader. -`ownsLoader(true)` means the instance closes the loader on shutdown, `saveOnShutdown(true)` means it -writes its chunks first, and the shutdown task is registered for you — the three together are the -part that is easy to get wrong by hand, because a loader closed before the save loses the save. + ```java + import net.minestom.server.MinecraftServer; + import net.minestom.server.world.DimensionType; + import net.onelitefeather.falco.anvil.FalcoAnvilLoader; + import net.onelitefeather.falco.instance.FalcoInstance; -If you register by hand instead, `shutdown(InstanceManager)` does the same work and -`unregister(InstanceManager)` unregisters without saving. + import java.nio.file.Path; -## With the light engine + FalcoAnvilLoader loader = new FalcoAnvilLoader(Path.of("worlds", "lobby"), DimensionType.OVERWORLD.key()); -This is the combination that did not work before `1.0.0`: + FalcoInstance instance = FalcoInstance.builder(DimensionType.OVERWORLD) + .chunkLoader(loader) + .autoChunkLoad(true) + .ownsLoader(true) + .saveOnShutdown(true) + .registerAndShutdownWith(MinecraftServer.getInstanceManager(), + MinecraftServer.getSchedulerManager()); + ``` -```java -// The scheduler takes the light service, not the instance — it binds to an instance later, -// through the chunks its supplier produces. -ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService()); + `registerAndShutdownWith` is the form to prefer when the instance owns its loader. The reasons for the + three flags are in [Explanation Why falco-instance exists](../explanation/why-falco-instance-exists.md#owning-the-loader-and-saving-on-shutdown). -FalcoInstance instance = FalcoInstance.builder(DimensionType.OVERWORLD) - .chunkLoader(loader) - .chunkSupplier(scheduler.supplier()) - .autoChunkLoad(true) - .register(MinecraftServer.getInstanceManager()); -``` +3. If you register by hand instead, `shutdown(InstanceManager)` does the same work, and + `unregister(InstanceManager)` unregisters without saving. -The supplier produces `FalcoLightingChunk`s, which extend `FalcoChunk`. Each one reports its block -changes, loads and ticks to the scheduler, and the scheduler lights the touched regions and sends -them. You write no listener. +## Add the light engine -`falco-light` declares `falco-instance` as `compileOnly`, so adding the light module does not bring -this one with it — declare both, or use the BOM. See [Add Falco to your build](add-falco-to-your-build.md). +4. Pass the scheduler's supplier to the builder, with the loader from step 2: -## Watching the lifecycle + ```java + import net.minestom.server.MinecraftServer; + import net.minestom.server.world.DimensionType; + import net.onelitefeather.falco.instance.FalcoInstance; + import net.onelitefeather.falco.light.ChunkLightScheduler; + import net.onelitefeather.falco.light.ChunkLightService; -**Minestom's own events still fire, and for most application code they are the right thing.** -`FalcoInstance` dispatches `InstanceChunkLoadEvent` and `InstanceChunkUnloadEvent`, and the block -writer dispatches `PlayerBlockBreakEvent`, so a listener registered on the `GlobalEventHandler` -works here exactly as it does on an `InstanceContainer`. Take those unless you have a reason not to. + // The scheduler takes the light service, not the instance — it binds to an instance later, + // through the chunks its supplier produces. + ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService()); -`ChunkLifecycleListener` is the other thing, and the difference is *when*, not *what*: + FalcoInstance instance = FalcoInstance.builder(DimensionType.OVERWORLD) + .chunkLoader(loader) + .chunkSupplier(scheduler.supplier()) + .autoChunkLoad(true) + .register(MinecraftServer.getInstanceManager()); + ``` -| | `ChunkLifecycleListener` | `InstanceChunkLoadEvent` | -| --- | --- | --- | -| when | during the transition, inside the lock | after it, once the chunk is published and the future completed | -| may call back into the instance | mostly no — stated per method | yes | -| a throw | fails the chunk load and reaches the caller waiting on it | does not | -| per block written | `onBlockChange`, no allocation | no equivalent | + The supplier produces `FalcoLightingChunk`s. You write no listener for the light; how the two parts + fit together is in [Explanation Why falco-instance exists](../explanation/why-falco-instance-exists.md#the-light-engine-on-the-instance). -Take the listener when you must act *before* anybody else sees the chunk, or when you need per-block -notification — which is what the light engine uses it for. Take the events for anything else. +## Listen to chunk loads and unloads -Two ways into the listener, depending on how much you need. +5. Pass two consumers to the builder, which is enough for most things: -The builder takes two consumers, which is enough for most things: + ```java + import net.minestom.server.MinecraftServer; + import net.minestom.server.world.DimensionType; + import net.onelitefeather.falco.instance.FalcoInstance; -```java -FalcoInstance.builder(DimensionType.OVERWORLD) - .chunkLifecycle( - chunk -> log.info("loaded {} {}", chunk.getChunkX(), chunk.getChunkZ()), - chunk -> log.info("unloaded {} {}", chunk.getChunkX(), chunk.getChunkZ())) - .register(manager); -``` + FalcoInstance.builder(DimensionType.OVERWORLD) + .chunkLifecycle( + chunk -> logger.info("loaded {} {}", chunk.getChunkX(), chunk.getChunkZ()), // your logger + chunk -> logger.info("unloaded {} {}", chunk.getChunkX(), chunk.getChunkZ())) + .register(MinecraftServer.getInstanceManager()); + ``` -For the full set, implement `ChunkLifecycleListener` and register it on the lifecycle. Every method -has a default, so implement only what you need: + Minestom's `InstanceChunkLoadEvent` and `InstanceChunkUnloadEvent` are also dispatched, and for most + application code they are the better choice. When to use which is explained in + [Explanation Why falco-instance exists](../explanation/why-falco-instance-exists.md#events-and-the-chunk-lifecycle-listener). -```java -instance.lifecycle().addListener(new ChunkLifecycleListener() { - @Override - public void onPublish(ChunkLifecycleEvent event) { } // visible to the world, before onLoad +6. For the full set of callbacks, implement `ChunkLifecycleListener` and register it on the lifecycle of + the instance. Every method has a default, so implement only what you need. The methods are listed in + [Reference API surface and stability](../reference/api-surface-and-stability.md#chunk-lifecycle-listener). - @Override - public void onLoad(ChunkLifecycleEvent event) { } + ```java + import net.minestom.server.instance.block.Block; + import net.onelitefeather.falco.instance.ChunkLifecycleListener; + import net.onelitefeather.falco.instance.FalcoChunk; - @Override - public void onTick(ChunkLifecycleEvent event) { } + instance.lifecycle().addListener(new ChunkLifecycleListener() { + @Override + public void onBlockChange(FalcoChunk chunk, int x, int y, int z, Block block) { + // record the position only, nothing else; see the two rules below + } + }); + ``` - @Override - public void onUnload(ChunkLifecycleEvent event) { } - - @Override - public void onBlockChange(FalcoChunk chunk, int x, int y, int z, Block block) { } -}); -``` - -Two things to know before you write one: - -- **`onBlockChange` runs inside the chunk's write lock.** Writing another block from it re-enters that - lock, and calling into code that takes a different lock is how a deadlock is built. Keep it to - recording the position. -- **A listener that throws fails the load** rather than being swallowed, and the caller waiting on the - chunk sees it. That is deliberate: a chunk whose listener failed is not a chunk anybody should be - handed. - -## Shared worlds - -A `FalcoInstance` **cannot** back a shared instance, and this has not changed: `SharedInstance` takes -an `InstanceContainer` and nothing else, so the attempt does not compile. What `FalcoSharedInstance` -does is fix a container-backed view: - -```java -InstanceManager manager = MinecraftServer.getInstanceManager(); -InstanceContainer world = manager.createInstanceContainer(); -world.setChunkSupplier(FalcoChunk::new); - -// Not manager.createSharedInstance(world): that factory always builds Minestom's own type. -FalcoSharedInstance view = new FalcoSharedInstance(UUID.randomUUID(), world); -manager.registerSharedInstance(view); -``` - -The order matters. `createInstanceContainer` registers the container, and the view's constructor -refuses one that is not registered — `registerSharedInstance` performs no such check, and an -unregistered container is ticked by nobody. - -The view keeps its own generator, chunk supplier and auto-load setting, where Minestom's writes all -three through to the container and lets one view reconfigure another. `saveInstance()` writes the -view's own tags rather than the container's. - -What it does not change is who owns the blocks: `setBlock` reaches the container, which serialises -every write on its own monitor. A world that needs Falco's write path uses `FalcoInstance` and gives -up sharing. + Two things to know before you write one: + + - **`onBlockChange` runs inside the chunk's write lock.** Writing another block from it re-enters that + lock, and calling into code that takes a different lock is how a deadlock is built. Keep it to + recording the position. + - **A listener that throws fails the load** rather than being swallowed, and the caller waiting on the + chunk sees it. That is deliberate: a chunk whose listener failed is not a chunk anybody should be + handed. +## Share a world through a container + +7. A `FalcoInstance` cannot back a shared instance. To get a shared view, create a container, give it the + Falco chunk type, and wrap it in a `FalcoSharedInstance`: + + ```java + import java.util.UUID; + import net.minestom.server.MinecraftServer; + import net.minestom.server.instance.InstanceContainer; + import net.minestom.server.instance.InstanceManager; + import net.onelitefeather.falco.instance.FalcoChunk; + import net.onelitefeather.falco.instance.FalcoSharedInstance; + + InstanceManager manager = MinecraftServer.getInstanceManager(); + InstanceContainer world = manager.createInstanceContainer(); + world.setChunkSupplier(FalcoChunk::new); + + // Not manager.createSharedInstance(world): that factory always builds Minestom's own type. + FalcoSharedInstance view = new FalcoSharedInstance(UUID.randomUUID(), world); + manager.registerSharedInstance(view); + ``` + + The view keeps its own generator, chunk supplier and auto-load setting. The order of the calls and the + limits of sharing are explained in [Explanation Why falco-instance exists](../explanation/why-falco-instance-exists.md#shared-instances-refused-by-the-compiler-which-is-the-good-outcome). ## Read a chunk without materialising its sections -`getSections()` and `getSection(int)` materialise every section they hand back, because Minestom's -contract lets a caller write into the result. On a lazy chunk that turns a read into 24 allocations. -`Heightmap#getHeight` does the same for the sections it descends through. - -Reach the storage with `chunk.storage()` and use the read-only counterparts: - -| To read | Use | Not | -| --- | --- | --- | -| one section | `view(int)` | `section(int)` | -| all sections | `views()` | `sections()` | -| how many exist | `materialisedSections()` | — | -| whether one is still the shared empty | `shared(int)` | — | +8. Read through `chunk.storage()` and not through `getSections()`, `getSection(int)` or `sections()`. The + read-only counterparts and what they replace are listed in + [Reference API surface and stability](../reference/api-surface-and-stability.md#read-only-section-access). ## Check it worked diff --git a/docs/reference/api-surface-and-stability.md b/docs/reference/api-surface-and-stability.md index 60bb6f4..ea29082 100644 --- a/docs/reference/api-surface-and-stability.md +++ b/docs/reference/api-surface-and-stability.md @@ -52,6 +52,67 @@ migration feature, and carry the same experimental annotation. (The package-private `SectorAllocator` and `ServiceResolution` carry no annotation and are not among the twenty-four.) +## Entry points and signatures + +| Type | Declaration | Notes | +| --- | --- | --- | +| `FalcoAnvilLoader` | `public FalcoAnvilLoader(Path worldRoot, Key dimension)` | Uses classpath discovery for both the version policy and the unknown-entry policy. | +| `FalcoAnvilLoader` | `public FalcoAnvilLoader(Path worldRoot, Key dimension, int openRegionLimit)` | The number of region files held open. The two-argument form uses `FalcoAnvilLoader.DEFAULT_OPEN_REGION_LIMIT`, which is `64`. | +| `FalcoAnvilLoader.builder()` | `Builder#build(Path worldRoot, Key dimension)` | Each slot is a setter on `FalcoAnvilLoader.Builder`. The migration slots are described in [Migration modes](migration-modes.md), the policy slots in [Replaceable policies](replaceable-policies.md). | +| `FalcoInstance` | `public FalcoInstance(UUID uuid, RegistryKey dimensionType)`, `public static Builder builder(RegistryKey dimensionType)` | The builder is immutable: every method returns a new one. | +| `FalcoSharedInstance` | `public FalcoSharedInstance(UUID uuid, InstanceContainer instanceContainer)` | The container must already be registered. | +| `ChunkLightScheduler` | `public ChunkLightScheduler(ChunkLightService service)` | One scheduler per instance; a second instance is refused with `IllegalStateException`. | + +## Chunk lifecycle listener + +`ChunkLifecycleListener` has a default for each method, so an implementation overrides only what it needs: + +```java +instance.lifecycle().addListener(new ChunkLifecycleListener() { + @Override + public void onPublish(ChunkLifecycleEvent event) { } // visible to the world, before onLoad + + @Override + public void onLoad(ChunkLifecycleEvent event) { } + + @Override + public void onTick(ChunkLifecycleEvent event) { } + + @Override + public void onUnload(ChunkLifecycleEvent event) { } + + @Override + public void onBlockChange(FalcoChunk chunk, int x, int y, int z, Block block) { } +}); +``` + +The builder shortcut `FalcoInstance.Builder#chunkLifecycle(Consumer, Consumer)` covers the load +and unload callbacks only. + +## Read-only section access + +`getSections()` and `getSection(int)` materialise every section they hand back, because Minestom's contract +lets a caller write into the result. On a lazy chunk that turns a read into 24 allocations. +`Heightmap#getHeight` does the same for the sections it descends through. The storage of a `FalcoChunk` is +reached through `chunk.storage()`, which offers read-only counterparts: + +| To read | Use | Not | +| --- | --- | --- | +| one section | `view(int)` | `section(int)` | +| all sections | `views()` | `sections()` | +| how many exist | `materialisedSections()` | — | +| whether one is still the shared empty | `shared(int)` | — | + +## Light engine types + +| Type | Responsibility | +| --- | --- | +| `FalcoLightingChunk` | The drop-in, a `DynamicChunk` subclass. Five overrides and no computation: `setBlock` reports the changed position, `onLoad` reports a change of unknown extent, `tick` triggers the pass, `invalidate` drops the cached light packet, and `onLightUpdated` — its half of `LightUpdateAware` — resends it. | +| `ChunkLightScheduler` | Everything else — the dirty set, the once-per-tick trigger, area forming, the executor, back pressure and the staleness rule — so a reader looking for the behaviour finds it in one place. | +| `ChunkArea` | A chunk coordinate pair, and the flood fill that cuts a dirty set into capped connected groups. Pure arithmetic, no Minestom. | +| `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 each chunk it has computed, so the next pass replays the reported changes on it. | +| `LightUpdateAware` | The hook Minestom does not have, so a computed result can be delivered without knowing which chunk type it belongs to. | + ## Opt-in only Nothing switches to a Falco type by itself. A server keeps using whatever loader, light path and diff --git a/docs/reference/benchmark-catalogue.md b/docs/reference/benchmark-catalogue.md index cb2f000..7158c85 100644 --- a/docs/reference/benchmark-catalogue.md +++ b/docs/reference/benchmark-catalogue.md @@ -136,6 +136,15 @@ exactly the run-to-run variability the single-fork objection is about, and it co code change to recover. +## Size of the full run + +`./gradlew jmh` runs every measured method for every combination of the parameters its state class declares: +50 methods over 488 configurations at commit `ca79507`, at the forks and iterations the annotations ask for. +The count is dominated by `ScalingBenchmark`, whose fifteen section counts and five distinct-state counts share +one state class and therefore form a full cross product of 300 configurations on their own. Budget well over +an hour for it, and do not touch the machine while it runs; see +[How-to Run the JMH benchmark suite](../how-to/run-the-jmh-benchmark-suite.md). + ## Notes on the setup | Piece | Version | Why | diff --git a/docs/reference/migration-modes.md b/docs/reference/migration-modes.md index e0af0f9..94edcb6 100644 --- a/docs/reference/migration-modes.md +++ b/docs/reference/migration-modes.md @@ -36,6 +36,7 @@ no longer read them. | Granularity | per region file, immediately before that file is first written | | Mechanism | written under a `.partial` name, then moved atomically | | Existing backup | never overwritten | +| Removal | none; Falco never deletes a backup, so it stays until it is removed by hand | A world whose chunks are all current is never copied at all. diff --git a/docs/reference/supported-versions.md b/docs/reference/supported-versions.md index 4211a08..6b7d146 100644 --- a/docs/reference/supported-versions.md +++ b/docs/reference/supported-versions.md @@ -62,5 +62,12 @@ supported. Only gzip (1), zlib (2) and none (3) can be read` | 26.1 and later | `worldRoot/dimensions///region` | | pre-26.1 | `worldRoot/region` — used only when the above does not exist | +## Artefact availability + +`falco-bom` is not served for releases before `1.0.0`: it was added after `0.3.0` was cut. A 404 for +`falco-bom` at a version below `1.0.0` is therefore expected, and the modules of those releases are +declared with their own versions. `falco-migration` is not in the BOM. See +[Reference Modules and coordinates](modules-and-coordinates.md) for every coordinate. + Related: [Explanation The chunk version guard](../explanation/the-chunk-version-guard.md) · [How-to Migrate a world from an older version](../how-to/migrate-a-world-from-an-older-version.md) · [Reference Modules and coordinates](modules-and-coordinates.md) diff --git a/docs/tutorials/load-your-first-world-with-falco.md b/docs/tutorials/load-your-first-world-with-falco.md index 5fa616d..d6d4ed6 100644 --- a/docs/tutorials/load-your-first-world-with-falco.md +++ b/docs/tutorials/load-your-first-world-with-falco.md @@ -6,6 +6,10 @@ By the end of this you will have a Minestom server that serves a stored Anvil wo You need Java 25, a Gradle project, and a saved Minecraft world directory. Nothing else. This takes about fifteen minutes. +This tutorial follows one path: a world saved by Minecraft 26.1 or later. If your world predates 26.1, +follow [How-to Load an Anvil world](../how-to/load-an-anvil-world.md) instead; it covers the older +layout. + ## Declare the dependencies 1. Add the OneLiteFeather repository and the Falco platform to `build.gradle.kts`: @@ -31,11 +35,10 @@ You should now see `net.onelitefeather.falco.anvil.FalcoAnvilLoader` resolve in ## Put the world where the loader will find it -1. Copy your saved world directory into your project as `worlds/lobby`. -2. Confirm it contains a `region` directory holding `r...mca` files — either directly at - `worlds/lobby/region`, or under `worlds/lobby/dimensions/minecraft/overworld/region`. +1. Copy your saved world directory into your project as `worlds/lobby`, so that the region files sit at + `worlds/lobby/dimensions/minecraft/overworld/region`. -You should now see at least one `.mca` file inside that directory. +You should now see at least one `r...mca` file inside that `region` directory. ## Serve the world @@ -48,6 +51,7 @@ You should now see at least one `.mca` file inside that directory. import net.minestom.server.world.DimensionType; import net.onelitefeather.falco.anvil.FalcoAnvilLoader; + import java.io.IOException; import java.nio.file.Path; public final class FirstServer { @@ -64,7 +68,13 @@ You should now see at least one `.mca` file inside that directory. instance.setChunkLoader(loader); instance.enableAutoChunkLoad(true); - Runtime.getRuntime().addShutdownHook(new Thread(loader::close)); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + loader.close(); + } catch (IOException exception) { + exception.printStackTrace(); + } + })); server.start("0.0.0.0", 25565); } @@ -79,35 +89,38 @@ freshly generated terrain. ## Light it -1. Add the scheduler, just above `server.start`: +1. Add two imports to the list at the top of `FirstServer.java`: ```java import net.onelitefeather.falco.light.ChunkLightScheduler; import net.onelitefeather.falco.light.ChunkLightService; + ``` + +2. Add the scheduler to `main`, just above `server.start`: + ```java ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService()); instance.setChunkSupplier(scheduler.supplier()); ``` -2. Run it again and place a torch in a dark room. +3. Run it again, walk into a dark room, and place a torch. -You should now see the light spread from the torch on the next tick, without any call of your own. +You should now see the blocks next to the torch lit within one tick, and the light fade with distance, +without any call of your own. A room that stays dark after the torch is placed means the scheduler step +was skipped. ## Shut it down cleanly -1. Stop the server. +1. Stop the server with Ctrl+C. You should now see a second `FalcoAnvilLoader` line at `INFO`: the close summary, naming loaded chunks, skipped chunks, saved chunks and errors. `errors=0` is what a clean run looks like. If it is above zero the line is logged at `WARN` instead, so a shutdown that lost chunks does not read like a -clean one. - -## What you did +clean one. The levels and the second line are described in [Reference Logging](../reference/logging.md). -You replaced Minestom's chunk loader with one that refuses to hand back a generated chunk when a -stored one fails to read, and you handed light computation to a scheduler that keeps chunks correct -on its own. Both were opt-in: nothing in Falco switches itself on. +## Next -Next: [How-to Load an Anvil world](../how-to/load-an-anvil-world.md) covers the map-provider form and the cached-region limit, and -[Explanation Choosing between Falco and the built-in loader](../explanation/choosing-between-falco-and-the-built-in-loader.md) explains which of the differences -between the two loaders actually carry a decision. +- [How-to Load an Anvil world](../how-to/load-an-anvil-world.md) covers the map-provider form and the + cached-region limit. +- [Explanation Choosing between Falco and the built-in loader](../explanation/choosing-between-falco-and-the-built-in-loader.md) + explains which of the differences between the two loaders actually carry a decision.