Skip to content

fix(light): read the diagonal chunks of an area's ring - #24

Merged
TheMeinerLP merged 2 commits into
mainfrom
fix/light-area-diagonals
Aug 2, 2026
Merged

fix(light): read the diagonal chunks of an area's ring#24
TheMeinerLP merged 2 commits into
mainfrom
fix/light-area-diagonals

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Proposed changes

Open item 2 of Project Status:
the ring of an area was built from the face neighbours of every chunk in it, so a chunk touching the
area only at a corner was never read.

Its light does reach the area — through the chunk between the two. But that chunk is a ring chunk,
and a ring chunk's state is computed from its own block states alone, so what it received from its
diagonal neighbour is not in it. The source was simply absent from the result.

Measured, not described

A glowstone in the corner of the diagonal chunk, two blocks from the corner of the area:

expected: <13> but was: <0>

Not "dimmer than it should be" — absent. With the four offsets added it arrives as 13, which is
15 minus the two blocks it travelled.

Every other test in ChunkLightAreaTest places its sources inside the area or on a face, which is
exactly why this survived: the gap is invisible unless a test is written for it.

The cost, counted rather than estimated

Project Status names the trade — "the ring grows, and the ring is what every pass pays for" — and
that wording suggests the ring grows with the area. It does not. It grows by the corners of its
bounding box
, which is a constant for any compact shape:

Area chunks read before after change
1×1 5 9 +4 (80 %)
2×2 12 16 +4 (33 %)
4×4 (the default cap) 32 36 +4 (12 %)
1×16 line 50 54 +4 (8 %)
16-chunk staircase 36 54 +18 (50 %)

The staircase is the honest worst case: a shape that runs diagonally adds a corner per step. fill
can produce one, since it walks face neighbours, but the shape a scheduler actually produces is a
cluster around a player, where the figure is the 12 % row.

No JMH run behind this. The counts are set arithmetic over the offsets — exact and reproducible,
but chunk counts rather than microseconds. If you want the µs before merging, say so and I will run
the light benchmarks with the configured forks and iterations; I did not want to publish a smoke-run
number in a project that keeps Measured Results
as strictly as this one does.

Why this was not simply "the obvious fix"

Project Status also records the consequence: this gap is why calculateWithNeighbours was not
deprecated in favour of ChunkLightArea, because for a single chunk the older method — which reads
the full 3×3 — was the more accurate of the two. With this, the area reads the same 3×3 for a
one-chunk area, and that argument falls away. The two methods now differ in cost, not in what they
can see.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Performance change (behaviour unchanged, cost changed)
  • Documentation Update (if none of the other choices apply)

Both boxes on purpose: the light a caller gets changes — it gains a source it was missing — and the
work per pass changes with it.

Checklist

  • I have read the CONTRIBUTING.md
  • The title of this pull request is a Conventional Commit
  • I have added tests that prove my fix is effective or that my feature works, and I wrote them before the
    implementation and watched them fail for the right reason
  • Tests are package-private, named test<What><Expectation>, and use plain JUnit assertions
  • Every new or changed class and method carries Javadoc
  • I have not written @NotNull; the package @NotNullByDefault covers it
  • ./gradlew build is green locally — 673 tests, 0 failures
  • I have added necessary documentation (if appropriate)

The equivalence tests against Minestom's engine stay green, which is worth stating: the extra source
does not push the result away from what Minestom computes.

Documentation not touched. Project Status item 2 describes this as open and the wiki is yours to
sequence; the numbers above are ready to move there if you want them.

Measurements

No timing claim is made. The table above is a chunk count derived from the offsets, and the section
header exists so it is clear that this is arithmetic and not a benchmark.

🤖 Generated with Claude Code

TheMeinerLP and others added 2 commits August 2, 2026 12:29
Open item 1 of Project Status ended on "nothing in the API says so yet", and
that was the whole of it: the behaviour is understood and narrowed twice, but
a caller reading the class could not learn it.

The class javadoc says a single instance may be used by as many threads as one
likes, and argues it from the service holding no state. That argument is sound
and answers a different question than the one a caller has. Two calls share no
state inside the service; they can still share the chunks they read, and
calculateWithNeighbours reads nine of them. Two threads on overlapping
neighbourhoods therefore have one reading what the other writes - not memory
corruption, since every write holds the chunk's write lock, but a result
committed from bytes that were already stale, which a player sees as a seam and
which set() then makes permanent by clearing the update flag.

Both the class and the method now say that, and both name the way out:
ChunkLightScheduler never submits overlapping work, so the obligation belongs
to whoever calls the service directly.

That promise about the scheduler had no test, which is a poor thing to lean on
in a javadoc. testAChunkUnderComputationIsNotSubmittedASecondTime pins it: an
executor that holds its areas instead of running them keeps the first one in
flight, a second tick submits nothing, and once the area finishes the chunk is
submitted again.

No production behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Open item 2 of Project Status. The ring was built from the face neighbours of
every chunk in the area, so a chunk touching it only at a corner was never
read. Its light does reach the area - through the chunk between the two - but
that chunk is a ring chunk, and a ring chunk's state is computed from its own
block states alone, so what it received from its diagonal neighbour is not in
it. The source was simply absent from the result.

The test measures it rather than describing it: a glowstone in the corner of
the diagonal chunk, two blocks from the corner of the area, arrives as 0 today
and as 13 with the four offsets added. Every other test in the file places its
sources inside the area or on a face, which is why nothing caught this.

The cost, counted rather than estimated: for a compact area it is the four
corners of the bounding box regardless of size, so a 4x4 area reads 36 chunks
instead of 32, a 1x16 line 54 instead of 50. A shape that runs diagonally pays
more - a staircase of 16 chunks goes from 36 to 54. That is the trade Project
Status names, and it is bounded in a way the wording there does not suggest:
the ring does not grow with the area, it grows by its corners.

No JMH run behind this. The counts are set arithmetic over the offsets, exact
and reproducible, but they are chunk counts and not microseconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TheMeinerLP
TheMeinerLP requested a review from a team as a code owner August 2, 2026 10:43
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Test results

  195 files    195 suites   4m 20s ⏱️
  670 tests   670 ✅ 0 💤 0 ❌
2 019 runs  2 019 ✅ 0 💤 0 ❌

Results for commit 8805178.

@TheMeinerLP
TheMeinerLP merged commit 6e59cb7 into main Aug 2, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant