Skip to content

fix: stale bulk-adopted snapshot overwrote freshly drawn voxels - #102

Merged
dsrw merged 1 commit into
mainfrom
fix-voxel-publish-and-bounds
Jul 27, 2026
Merged

fix: stale bulk-adopted snapshot overwrote freshly drawn voxels#102
dsrw merged 1 commit into
mainfrom
fix-voxel-publish-and-bounds

Conversation

@dsrw

@dsrw dsrw commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Chunks of a build could render with most of their voxels missing. It survived reloads, was invisible to the eye in debug builds, and a script re-run "fixed" it — which is what made it so slippery.

Root cause

A build's persisted edits are bulk-adopted at load and staged in pending_snapshots to pace the sync channel. flush_dirty_chunks drained the script's draws first and the staged blobs second, publishing each blob verbatim over packed_chunks and clearing the chunk's deltas:

self.packed_chunks[chunk_id] = blob   # clobbers the draws
if chunk_id in self.chunk_deltas:
  self.chunk_deltas[chunk_id].clear   # and their deltas

So when a chunk's draws and its staged blob landed in the same flush pass, the edits-only blob won. Nothing retried — the chunk published fewer voxels than the store held and stayed that way until the script re-ran.

Evidence from the reproduction (tutorial-3 maze, local chunk (-2,0,0)):

voxels
worker live cells 326
worker published 2
worker pending none
main 2

2 is exactly that chunk's persisted-edit count. The three neighbouring chunks in the same region have zero persisted edits and were never affected.

That also explains the symptoms: release-only (release draws fast enough for both to be pending together; debug usually drains the snapshot in an earlier pass), load-order sensitive (shifts when the script runs relative to the flush cadence), and fixed by re-running the script (pending_snapshots is only staged at load).

The rule this restores

A snapshot supersedes a chunk's deltas and pending changes iff it was encoded from that chunk's current cells.

flush_chunk_snapshot satisfies that — it encodes the cells, which already fold packed + deltas + pending. A staged blob does not, at publish time.

So: drain staged snapshots first, so an adopted blob sits underneath anything drawn since; publish verbatim only while the chunk is untouched, else re-encode from the cells and drop the superseded pending entry. Ordering alone isn't sufficient — the worker breaks out of the iterator under channel pressure, so a staged blob can outlive a pass in which draws flushed. The fresh-load fast path is unchanged: nothing is dirty then, so every chunk still takes the verbatim route.

Also: unit bounds

The terrain clips chunk loading and meshing to Build.bounds, and bounds could come up short:

  • expand_bounds_to_chunk guarded on AABB.contains, which compares point.z against position.x in godot-nim (see fix: AABB.contains compared point.z against position.x godot-nim#1) — expansions were silently dropped once a unit reached further along -x than -z. Now unconditional and idempotent.
  • The two on_chunk_created fire sites had opposite count guards, and a third path never reported at all. All routed through note_chunk.
  • unload_chunk now warns when page-out drops unflushed writes — cached_chunk's eviction explicitly refuses that trade; unload_chunk didn't guard it.

Verification

Deterministic harness: a pristine level fixture restored before every run (Enu rewrites load_order on load, which silently changed the input between trials otherwise), release build, cold load, scored as pixel diff against a known-good render.

  • Before: 2/2 broken (1.957%, 1.848% of sampled pixels differing)
  • After: 3/3 healthy (0.109%, 0.109%, 0.000%)

nim build, nim build -d:release, and nim test_all all pass. tests/unit/bounds_test.nim is new and registered in tasks.nim (unit tests are listed explicitly, not discovered); its -z-chunk case fails on the old bounds code and passes on the new.

Level data is deliberately not included in this PR.

🤖 Generated with Claude Code

A build's persisted edits are bulk-adopted at load and staged in
`pending_snapshots` to pace the sync channel. `flush_dirty_chunks` drained
the script's draws first and the staged blobs second, publishing each blob
verbatim over `packed_chunks` and clearing the chunk's deltas — so when a
chunk's draws and its staged blob landed in the same flush pass, the
edits-only blob won and the drawing was lost. Nothing retried: the chunk
published fewer voxels than the store held, and stayed that way until the
script re-ran.

Only chunks carrying persisted edits could be hit, and they collapsed to
exactly their edit count. Release builds draw fast enough for both to be
pending together, which is why it rarely showed in debug.

Drain the staged snapshots first, so an adopted blob sits underneath
anything drawn since, and publish it verbatim only while the chunk is
untouched — otherwise re-encode from the cells, which already fold
packed + deltas + pending, and drop the superseded pending entry.
Ordering alone isn't enough: the worker breaks out of the iterator under
channel pressure, so a staged blob can outlive a pass in which draws
flushed.

Also fixes unit bounds, which clipped terrain loading and meshing:

- expand_bounds_to_chunk guarded on AABB.contains, which compares point.z
  against position.x in godot-nim, so expansions were silently dropped
  once a unit reached further along -x than -z. Grow unconditionally.
- The two on_chunk_created fire sites had opposite count guards and a
  third path never reported at all. Route them through note_chunk.
- Warn when page-out drops unflushed writes — cached_chunk's eviction
  refuses that trade, unload_chunk didn't guard it.
@dsrw
dsrw merged commit d7e20f6 into main Jul 27, 2026
3 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