Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
8355d4f
feat(bom): add falco-bom, a platform pinning the published modules
TheMeinerLP Aug 1, 2026
ca79507
docs: move the build script rationale to the wiki
TheMeinerLP Aug 1, 2026
0297d3c
docs(readme): tie every figure to its conditions and restructure the …
TheMeinerLP Aug 1, 2026
627e766
docs(spec): design a chunk, an instance and a shared instance of Falc…
TheMeinerLP Aug 1, 2026
3634a6c
docs(plan): plan stage 1, the seam the later stages need
TheMeinerLP Aug 1, 2026
6ec6973
test(benchmarks): measure the chunk, the palette and the instance aga…
TheMeinerLP Aug 1, 2026
63d2960
feat(instance): introduce BlockStorage, the implementation side of th…
TheMeinerLP Aug 1, 2026
7c3d575
feat(instance): store blocks in sections behind BlockStorage
TheMeinerLP Aug 1, 2026
dc97ad2
refactor(instance): hold block storage instead of inheriting it
TheMeinerLP Aug 1, 2026
6c5a971
test(instance): prove the bridge chunk agrees with DynamicChunk every…
TheMeinerLP Aug 1, 2026
56f180a
test(instance): run the bridge chunk inside a plain InstanceContainer
TheMeinerLP Aug 1, 2026
125c870
test(benchmarks): assert what the seam costs instead of assuming it i…
TheMeinerLP Aug 1, 2026
78b6a89
fix(instance): restore the guards and the contract the storage seam d…
TheMeinerLP Aug 1, 2026
cc02fef
docs(benchmarks): correct what the control benchmark claims about the…
TheMeinerLP Aug 1, 2026
099a44d
docs(plan): name the run that carries US-1.03
TheMeinerLP Aug 1, 2026
d7f17b8
docs(plan): plan stage 2, where the memory actually goes down
TheMeinerLP Aug 1, 2026
486a33c
feat(instance): give the storage a read-only way to look at a section
TheMeinerLP Aug 1, 2026
30b0fa9
docs(instance): raise the test class version the last change forgot
TheMeinerLP Aug 1, 2026
9b5a020
feat(instance): share one empty section instead of allocating twenty-…
TheMeinerLP Aug 1, 2026
dde0369
test(instance): make the shared-read case fail when the US-2.07 short…
TheMeinerLP Aug 1, 2026
5b52fca
refactor(instance): read the chunk's own sections without creating them
TheMeinerLP Aug 1, 2026
19c222a
fix(benchmarks): say which storage the chunk comparison runs on, and …
TheMeinerLP Aug 1, 2026
b60cf9d
fix(benchmarks): stop the equivalence check from materialising what i…
TheMeinerLP Aug 2, 2026
34eb462
test(instance): state what every boundary caller makes a lazy chunk a…
TheMeinerLP Aug 2, 2026
f790f0d
test(benchmarks): price the palette optimisation against the commit i…
TheMeinerLP Aug 2, 2026
88e71b3
test(benchmarks): stage the commit fixture the way a generator leaves it
TheMeinerLP Aug 2, 2026
ae73096
test(benchmarks): make the fixture guard check the thing that can act…
TheMeinerLP Aug 2, 2026
de4d90e
test(benchmarks): stage the commit fixture through setAll instead of …
TheMeinerLP Aug 2, 2026
44e5b25
feat(instance): commit only the sections a generator filled, and pack…
TheMeinerLP Aug 2, 2026
828c35c
perf(instance): ask whether a palette can still be narrowed before na…
TheMeinerLP Aug 2, 2026
40da0e3
test(instance): cover the two skip clauses no case reached, and name …
TheMeinerLP Aug 2, 2026
53a1550
perf(instance): build a heightmap when something asks for one
TheMeinerLP Aug 2, 2026
dc40710
docs(instance): say which chunk the heightmap share is a share of
TheMeinerLP Aug 2, 2026
556ac40
test(instance): pin what ticking a chunk does before the second map goes
TheMeinerLP Aug 2, 2026
bbcf44f
perf(instance): keep one block map and a counter instead of two maps
TheMeinerLP Aug 2, 2026
c82d4be
test(instance): make the tickable counter's arithmetic observable
TheMeinerLP Aug 2, 2026
d56c3d0
docs(benchmarks): correct the three places that still describe a seco…
TheMeinerLP Aug 2, 2026
3c9b114
test(benchmarks): declare what the lazy chunk no longer holds, class …
TheMeinerLP Aug 2, 2026
083a5f8
docs: hand the instance and chunk work over to a session that was not…
TheMeinerLP Aug 2, 2026
b1da4b4
docs(benchmarks): name the one defect the footprint comparison cannot…
TheMeinerLP Aug 2, 2026
7424422
docs(benchmarks): say which rows of the fresh table the plan got wrong
TheMeinerLP Aug 2, 2026
c8b916f
test(benchmarks): compare the two chunks class by class instead of by…
TheMeinerLP Aug 2, 2026
9475066
docs(plan): record what stage 2 bought and what it did not
TheMeinerLP Aug 2, 2026
de6e140
docs: mark stage 2 done in the handoff
TheMeinerLP Aug 2, 2026
a5ad29f
fix(instance): publish a materialised section with a compare and exch…
TheMeinerLP Aug 2, 2026
9b138e6
fix(instance): commit every palette before the first special block is…
TheMeinerLP Aug 2, 2026
f9c1c8b
docs(instance): retire the footprint figures that tasks 7 and 8 super…
TheMeinerLP Aug 2, 2026
79a8269
docs(plan): plan stage 3, the instance split and the listeners it ena…
TheMeinerLP Aug 2, 2026
ea77687
Merge branch 'main' into feat/block-storage
TheMeinerLP Aug 2, 2026
e25802f
docs: record the tick race the merge surfaced and did not fix
TheMeinerLP Aug 2, 2026
83276e5
test(instance): pin the block write paths before they move
TheMeinerLP Aug 2, 2026
b88db64
test(instance): pin the save paths before they move
TheMeinerLP Aug 2, 2026
758af91
test(instance): name the break case after what it actually asserts
TheMeinerLP Aug 2, 2026
07aa7f2
test(instance): pin the placement rule branch of a block write
TheMeinerLP Aug 2, 2026
e561b83
refactor(instance): give the chunk positions a registry of their own
TheMeinerLP Aug 2, 2026
74d585e
refactor(instance): point FalcoInstance at the chunk registry
TheMeinerLP Aug 2, 2026
93546e6
docs(instance): write down what a step inside the position lock may do
TheMeinerLP Aug 2, 2026
874f2fd
refactor(instance): give the chunk loader a part of its own
TheMeinerLP Aug 2, 2026
f352feb
refactor(instance): point FalcoInstance at the chunk persistence
TheMeinerLP Aug 2, 2026
200ec54
refactor(instance): move the generator and its forks into ChunkGenera…
TheMeinerLP Aug 2, 2026
f91000f
docs(instance): raise the version of the three classes whose referenc…
TheMeinerLP Aug 2, 2026
d67b8b2
refactor(instance): make publish and load completion reachable one at…
TheMeinerLP Aug 2, 2026
781e178
docs(instance): name the class that actually hands in the two steps
TheMeinerLP Aug 2, 2026
b7d4425
fix(instance): let the loader read an instance whose lifecycle alread…
TheMeinerLP Aug 2, 2026
7b03928
refactor(instance): give the block write path a class of its own
TheMeinerLP Aug 2, 2026
d9cf73e
docs(instance): say how far the chunk write lock actually reaches
TheMeinerLP Aug 2, 2026
4476022
refactor(instance): make the instance a facade and assert that it sta…
TheMeinerLP Aug 2, 2026
f2f5906
docs(instance): say what the facade actually promises, not what it ne…
TheMeinerLP Aug 2, 2026
83825cc
feat(instance): let a chunk carry more than one lifecycle extension
TheMeinerLP Aug 2, 2026
571d08c
docs(instance): name the two things a listener can be surprised by
TheMeinerLP Aug 2, 2026
fa71013
fix(instance): give a loaded chunk the listener too, not only a gener…
TheMeinerLP Aug 2, 2026
0f0f48a
fix(instance): give the viewer cache entry back when a chunk unloads
TheMeinerLP Aug 2, 2026
4401cf2
test(instance): tell a targeted release apart from a wipe
TheMeinerLP Aug 2, 2026
8cbf4d2
feat(light): put the lifecycle and the light on one chunk instance
TheMeinerLP Aug 2, 2026
5a0584e
docs(instance): stop pointing the lifecycle pair at the chunk that ou…
TheMeinerLP Aug 2, 2026
c288f84
docs: say what the second arm of the lifecycle costs a listener
TheMeinerLP Aug 2, 2026
66f4210
perf(instance): look a chunk up without boxing its index
TheMeinerLP Aug 3, 2026
bf4b4e2
test(archunit): let the published modules see flare, and say why
TheMeinerLP Aug 3, 2026
dd4d0f7
docs(plan): record what stage 3 measured
TheMeinerLP Aug 3, 2026
6a37f9c
docs(benchmarks): stop the lookup benchmark asserting a cost it then …
TheMeinerLP Aug 3, 2026
721a18c
docs(instance): stop claiming a lookup takes no lock, and pin what it…
TheMeinerLP Aug 3, 2026
9ec302a
fix(instance): complete the load future when a lifecycle listener throws
TheMeinerLP Aug 3, 2026
770301c
docs(instance): name the fourth piece of foreign code under the chunk…
TheMeinerLP Aug 3, 2026
87ffd65
docs(plan): pull the stage 3 acceptance level with the closing review
TheMeinerLP Aug 3, 2026
50330be
docs(plan): take the stage 3 gate at the commit the stage ends at
TheMeinerLP Aug 3, 2026
bfd9cc4
docs(plan): say what the two broken runs actually showed, not more
TheMeinerLP Aug 3, 2026
96d2c6c
build(api)!: declare FalcoLightingChunk's break instead of hiding it
TheMeinerLP Aug 3, 2026
54f373f
Merge remote-tracking branch 'origin/main' into feat/block-storage
TheMeinerLP Aug 3, 2026
0710238
ci: skip the footprint tests on macOS, where they hang before the fir…
TheMeinerLP Aug 3, 2026
dbb213d
docs(handoff): bring the handoff level with four finished stages
TheMeinerLP Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ Thumbs.db
# git add -A picks an existing one up as an embedded repository, which leaves a
# gitlink to a directory nobody else has.
.claude/

# A second world, used to count the empty section share of a generated overworld
# (EmptySectionCensusTest, -Pfalco.census.world). falco-demo/world carries its own
# .gitignore because that directory is part of the module; this one is not, and a
# world is large, often private, and never belongs in a git repository.
falco-demo/full-world/
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ The listener is the explicit route: you decide which chunks are lit and when. Th
that needs no listener at all — `instance.setChunkSupplier(scheduler.supplier())`, covered in
[Light Engine](https://github.com/OneLiteFeatherNET/Falco/wiki/Light-Engine).

**That shorter route needs `falco-instance` on the classpath as well**, and the two lines above are
not enough for it. The chunks the supplier produces are `FalcoChunk`s — which is what lets one chunk
carry Falco's light *and* Falco's lifecycle instead of forcing a choice between them — and
`falco-instance` is `compileOnly` in `falco-light`, so it does not arrive with the artefact. Add
`implementation("net.onelitefeather:falco-instance:<version>")` next to the two above before calling
`supplier()`; everything else in `falco-light`, including the `lighting.calculate` route used here,
works without it.

### 3. Put a world where the loader looks

`worlds/lobby/` is the **world root** — the directory that contains `region/`, or
Expand Down
24 changes: 24 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ project(":falco-bom") {

val apiBaselineVersion: String = providers.gradleProperty("apiBaselineVersion").get()

val apiBreaksFile: File = rootProject.file("gradle/api-breaks.properties")

val apiBreaks: Map<String, String> = if (!apiBreaksFile.exists()) emptyMap() else
java.util.Properties()
.apply { apiBreaksFile.inputStream().use { load(it) } }
.entries
.associate { it.key.toString() to it.value.toString().trim() }

configure(publishedModules - project(":falco-bom")) {
apply(plugin = "me.champeau.gradle.japicmp")

Expand All @@ -120,16 +128,32 @@ configure(publishedModules - project(":falco-bom")) {
isTransitive = false
}

val declaredBreaks: List<String> = apiBreaks["${project.name}.classExcludes"]
?.split(",")
?.map(String::trim)
?.filter(String::isNotEmpty)
.orEmpty()

val checkApiCompatibility = tasks.register<me.champeau.gradle.japicmp.JapicmpTask>("checkApiCompatibility") {
oldClasspath.from(apiBaseline)
newClasspath.from(tasks.named<Jar>("jar").flatMap { it.archiveFile })
onlyBinaryIncompatibleModified.set(true)
failOnModification.set(true)
ignoreMissingClasses.set(true)
classExcludes.set(declaredBreaks)
htmlOutputFile.set(layout.buildDirectory.file("reports/japicmp/${project.name}.html"))
txtOutputFile.set(layout.buildDirectory.file("reports/japicmp/${project.name}.txt"))

doFirst {
if (apiBreaks.keys.any { it.endsWith(".classExcludes") }) {
require(apiBreaks["baseline"] == apiBaselineVersion) {
"${apiBreaksFile.name} declares accepted API breaks against baseline " +
"${apiBreaks["baseline"]}, but apiBaselineVersion is $apiBaselineVersion. " +
"Every exception in that file was judged against the older baseline and " +
"excludes its type from the check entirely, so each one has to be " +
"re-examined and either deleted or re-justified before the version moves."
}
}
val resolved = apiBaseline.resolve()
require(resolved.isNotEmpty()) {
"the API baseline net.onelitefeather:${project.name}:$apiBaselineVersion resolved to nothing"
Expand Down
164 changes: 164 additions & 0 deletions docs/benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Benchmark baselines

This directory holds the JMH result files every published figure of this project is drawn from, and
the record of the machine each of them was taken on. It is the durable half of `falco-benchmarks`:
the benchmark sources say what is measured and why, these files say what came out.

## Why the results do not live in `build/`

The `jmh` block of `falco-benchmarks/build.gradle.kts` writes to
`build/reports/jmh/results.json` by default, and `./gradlew clean` deletes it. A baseline that a
routine clean removes is not a baseline — the next run has nothing to be compared against, and the
figure in the README turns back into a claim. Result files therefore land here, under version
control, next to the conditions that produced them.

Two Gradle properties make the Gradle path write here as well, so a run started with
`./gradlew :falco-benchmarks:jmh` does not have to be repeated through the jar to be kept:

```
./gradlew :falco-benchmarks:jmh \
-Pjmh.include=SectionAllocationBenchmark \
-Pjmh.forks=3 \
-Pjmh.resultsFile=docs/benchmarks/baseline-2026-08-01/SectionAllocationBenchmark.json \
-Pjmh.humanFile=docs/benchmarks/baseline-2026-08-01/SectionAllocationBenchmark.human.txt
```

Both paths are resolved against the repository root, and an absolute path is taken as it is.
`-Pjmh.forks` exists alongside them because the classes carry `@Fork(1)` and the Gradle path would
otherwise silently produce a single-fork result while the script produces a three-fork one. It is
applied after `-Pjmh.quick`, so passing both leaves the fork count where `-Pjmh.forks` puts it.

## Layout

```
docs/benchmarks/
full-run.sh the script that produces a baseline
baseline-<yyyy-mm-dd>/
conditions.txt machine, JVM, commit, configuration, idle answer
<BenchmarkClass>.json JMH result, one file per class
<BenchmarkClass>.human.txt the printed transcript of the same run
SetBlockContentionBenchmark-t<N>.json one file per thread count
```

**One file per benchmark class, never one shared file.** JMH rewrites `-rff` completely on every
invocation rather than appending to it, so a second run into the same path destroys the first. That
is not a hypothetical: the scouting run of 2026-08-01 pointed six invocations at the single
`build/reports/jmh/results-quick.json` the build configures, and only the last of the six survived
in it. The same applies to the thread sweep of `SetBlockContentionBenchmark`, where five processes
run in a row and the fifth would otherwise be the only one left.

## Running a baseline

```
docs/benchmarks/full-run.sh --dry-run # print every command and the time estimate
docs/benchmarks/full-run.sh # about 2 h 35 min
```

The script derives its estimate per benchmark class and prints the derivation in its header. Read
it before starting: this is a run measured in hours, not minutes, and it needs the machine to
itself for all of them. It refuses to start above a one minute load average of 1.5 for that reason.
A Gradle build, an IDE indexing pass or a second agent compiling in the same checkout is enough to
change the numbers, and none of it is visible afterwards in the result file.

The script builds the benchmark jar once with Gradle and then runs each measurement as a plain
`java -jar`. That is deliberate. Driving the measurements through `./gradlew :falco-benchmarks:jmh`
keeps a Gradle daemon alive next to every forked measurement JVM, competing for the same cores; the
jar path leaves exactly one JVM running while a benchmark is being measured.

## Comparing a later run against a baseline

Point the new run at a new file, never at the old one:

```
./gradlew :falco-benchmarks:jmhJar
java -jar falco-benchmarks/build/libs/falco-benchmarks-*-jmh.jar \
'ChunkComparisonBenchmark' -e '\.(minestomCopy|falcoCopy)$' \
-f 3 -wi 5 -i 5 -prof gc -foe true \
-rf json -rff /tmp/chunk-comparison-candidate.json
```

Then read the two files side by side — [JMH Visualizer](https://jmh.morethan.io/) takes several at
once — and read `conditions.txt` first. A comparison across two machines, two JVM builds or two
governor settings is not a comparison. Nothing in the JSON warns about it; the conditions file is
the only place that information exists.

## What is in a baseline and what is not

The run covers six benchmark classes at three forks, five warmup and five measurement iterations of
one second, with `-prof gc`. One thing is deliberately kept out of it, and one thing is deliberately
filed under a name that says it may not be quoted.

**`ChunkComparisonBenchmark.minestomCopy` and `.falcoCopy` are not part of the baseline.** They are
documented in their own javadoc as non-comparable: constructing a chunk for an `InstanceContainer`
leaves an entry in a viewer cache that nothing removes and that no later key ever matches, so
`minestomCopy` reports a copy plus a hash map that grows for the length of the trial.
`ChunkViewerCacheLeakTest` establishes the mechanism. The comparable pair is
`minestomCopyIsolated` against `falcoCopyIsolated`, and it is in the baseline.

The scouting run shows why the two arms cannot be a baseline even taken on their own terms. Their
per iteration values rise during the measurement — at `distinctStates = 64` the three iterations of
the `minestomCopy` fork read 296, 313 and 364 µs/op, and at 1024 they read 282, 304 and 390 µs/op,
a rise of 23 % and 38 % — while every control arm on the same fork is flat to within 2 %
(`falcoSetBlock` at 1024: 106.86, 107.19, 106.64). A quantity that grows while it is being measured
does not have a value; it has a slope, and the mean printed for it is a function of how long the
iteration ran. Changing `-i` changes the answer.

Their allocation column, in contrast, is exact and is worth having: `minestomCopy` minus
`falcoCopy` in `gc.alloc.rate.norm` was 257.1, 257.3 and 257.3 B/op at `distinctStates` 1, 64 and
1024 — constant across the axis, at an error below 0.6 B. That is the per copy cost of the leak, it
is the same whatever the chunk holds, and it is the number worth publishing about this pair.

The two arms are therefore run, once, under `--with-leak-arms`, into a file named
`ChunkComparisonBenchmark-viewer-cache-leak-NOT-A-BASELINE.json`. The name is the warning, because
the file will outlive the conversation that produced it.

## What has to be recorded next to a number

`conditions.txt` is written by the script and answers every field
[the performance report form](../../.github/ISSUE_TEMPLATE/performance-report.yml) asks reporters
for: CPU model and core count, the JMH thread count, the JVM vendor and version, the operating
system, the Falco commit, the exact configuration, and whether the machine was idle. The last one
is the only field the script cannot fill in, and it is left as an open question at the end of the
file. Answer it before quoting anything from the run. The figures currently on the wiki's Project
Status page were taken on a machine that was not idle and say so, which is the only reason they are
still usable.

## The tests of this module do not run on macOS

`:falco-benchmarks:test` is skipped on macOS and only there. Everything else in the repository runs
on all three runners as before; this module is the exception, and Gradle prints the reason next to
the `SKIPPED` marker rather than passing over it silently.

**What was observed.** On 2026-08-03 the macOS job of both open pull requests stopped in
`:falco-benchmarks:test` and never came back. The other five modules — instance, light, anvil, demo,
archunit — completed and wrote all 85 result files; this module wrote
`in-progress-results-generic.bin` and `output-events.bin` at zero bytes, meaning the test JVM had
been started and no test had reported anything at all. The job was silent for 31 minutes before it
was cancelled, and the runner then terminated four orphan `java` processes. The same commit builds
in 3m30s on ubuntu and 5m10s on windows, and the same macOS runner builds `main` green in 2m1s, so
neither the runner nor the workflow is what differs.

**Why this module and no other.** It is the only one whose test JVM is started with
`-Djdk.attach.allowAttachSelf=true`, `-XX:+EnableDynamicAgentLoading`, `-Djol.magicFieldOffset=true`
and an explicit `UseCompactObjectHeaders` setting, and with a 4 GB heap on a runner that has 7 GB.
Those exist because jol measures retained size by attaching to its own VM. Which of them is the one
that hangs on arm64 has not been established — the module is excluded, the cause is not diagnosed,
and this paragraph says so rather than implying otherwise.

**What is given up.** These are the tests that carry the central claim of the storage work:
`ChunkFootprintTest` measures the 25 objects and 840 bytes of a fresh chunk, `PaletteFootprintTest`
the palette break-even, `FalcoChunkEquivalenceTest` the behavioural equality against Minestom. They
keep running on ubuntu and windows in every pull request, so the claim stays covered on two of three
platforms — but a regression that only shows on arm64 would now pass unnoticed. The figures were
never platform independent to begin with: retained size depends on the object header layout, which
is what `UseCompactObjectHeaders` switches, so a number taken on arm64 was never interchangeable
with the published one.

**To run them on macOS anyway**, for instance to work on the hang:

```bash
./gradlew :falco-benchmarks:test -Pfalco.macOsFootprintTests
```

The property forces the task on regardless of the operating system. Expect it to hang until the
cause is found.
Loading
Loading