Skip to content

fix(portal): link nether portals whose chunks are unloaded 🤖🤖🤖 - #3203

Open
ydw1904 wants to merge 2 commits into
Pumpkin-MC:masterfrom
ydw1904:fix/nether-portal-cold-chunk-linking
Open

fix(portal): link nether portals whose chunks are unloaded 🤖🤖🤖#3203
ydw1904 wants to merge 2 commits into
Pumpkin-MC:masterfrom
ydw1904:fix/nether-portal-cold-chunk-linking

Conversation

@ydw1904

@ydw1904 ydw1904 commented Sep 3, 2026

Copy link
Copy Markdown

Description

Nether portals stop linking to each other once their chunks unload. Each crossing builds a fresh portal instead of reusing the existing one, so you end up with a chain of orphaned pairs and get dropped somewhere new every time you switch dimensions.

search_for_portal finds candidates in the POI index, which survives chunk unloading, then validates them with World::get_block_and_state. That resolves through read_chunk_sync, a bare loaded_chunks lookup that reports an unloaded chunk as air. Leaving a dimension unloads exactly the chunks the portal sits in, so on the return trip every candidate reads as air and is discarded, the search returns None, and the fallback builds a duplicate.

The chunk loading in PortalType::get_portal_destination only runs in the or_else arm, after the search has already failed, and the search is never retried.

This is a regression

#3001 fixed the same fault using the chunk-loading get_block_state_id_async. 5d841d6 (refactor(entities): from async to sync Part 2) swapped that accessor for the plain sync one:

-if world.get_block_state_id_async(&pos).await.to_block() != &Block::NETHER_PORTAL {
+if world.get_block_state_id(&pos).to_block() != &Block::NETHER_PORTAL {

The async accessor still exists but is unusable here now that the call path is sync, and it only ever guarded the first read — get_on_axis walks up to 21 blocks outward from the POI and crosses chunk borders unguarded.

Changes

Preload candidate chunks. Filter candidates by Y and world border first, drop the world-border guard, then load each survivor's chunk plus its 8 neighbours before any block read. Deduplicated by chunk coordinate, so one portal costs a single 3×3 load rather than one per portal block. Bridges Rayon → Tokio with the block_in_place + block_on pattern already used in portal/mod.rs. Vanilla gets this for free: Level#getBlockState loads the chunk it needs.

SEARCH_RADIUS_NETHER: 128 → 16. Vanilla reduced this in 1.16.2 "in order to correctly account for the 1:8 position scale" (wiki); the branch already keys on the destination dimension, only the value was wrong. At 128, two overworld portals up to 1024 blocks apart share one nether portal instead of each getting their own — walk into portal B, come back out of portal A. It was unreachable while the search above always failed, so it has to land here rather than in a follow-up. PaperMC hit the same divergence in PaperMC/Paper#3795 and PaperMC/Paper#4573.

Both changes are in one commit deliberately: shipping the chunk fix alone would activate the 1024-block snap bug that was previously dead code.

Testing

  • In-game: crossed to the nether, travelled far enough for the overworld chunks to unload, returned through the same portal — arrives at the original portal, no duplicate built. Fails on master.
  • cargo test — 256 pass
  • cargo clippy --all-targets — clean
  • New unit test for the chunk-set math: 3×3 union, dedupe across adjacent chunks, and negative coordinates (>> 4 floor-divides, -1 → -2)

There is no end-to-end regression test — that needs a World harness with force-unloaded chunks, which doesn't exist in the repo. The load-before-read ordering is guarded by a comment naming this regression's history so the next async→sync pass doesn't silently undo it again.

Not in scope

  • find_safe_location scans a ±32 box; vanilla createPortal uses BlockPos.spiralAround(pos, 16, …). Different size and shape, and its edges read past the chunks the or_else arm loads — same cold-chunk class, different function.
  • Teleport-time portal measuring uses the PortalShape obsidian-frame validator; vanilla uses BlockUtil.getLargestRectangleAround for both source and exit rectangles and ignores the frame. Affects sliced/partly-mined portals. Left alone deliberately — it interacts with block-update-suppression behaviour that's a judgement call for the core team.

AI assistance

Investigated and implemented with Claude Code (Opus 5). Root cause traced from the reported symptom, confirmed against the reporter's world save by decoding its POI region files, and verified in-game before submission. All vanilla behaviour cited above was checked against primary sources rather than recalled.

`search_for_portal` looks up candidates in the POI index, which survives chunk
unloading, then validates them with `World::get_block_and_state`. That resolves
through `read_chunk_sync`, a bare `loaded_chunks` lookup that reports an
unloaded chunk as air. Leaving a dimension unloads exactly the chunks the
portal sits in, so on the return trip every candidate read as air and was
discarded, the search returned `None`, and the fallback built a duplicate
portal. Repeating this stranded a new portal pair on each crossing.

This is a regression. Pumpkin-MC#3001 fixed the same fault with the chunk-loading
`get_block_state_id_async`; 5d841d6 ("refactor(entities): from async to sync
Part 2") swapped that accessor for the plain sync one and reintroduced it. The
async accessor is unusable here now that the call path is sync, and it only
ever guarded the first read anyway.

Load each surviving candidate's chunk plus its 8 neighbours before reading any
blocks, deduplicated by chunk coordinate so one portal costs a single 3x3 load
rather than one per portal block. Neighbours are required because `get_on_axis`
walks up to MAX_WIDTH/MAX_HEIGHT blocks out from the POI and crosses chunk
borders, which the old async accessor never covered. Vanilla gets this for
free: `Level#getBlockState` loads the chunk it needs.

Also correct SEARCH_RADIUS_NETHER from 128 to 16. Vanilla reduced it in 1.16.2
"in order to correctly account for the 1:8 position scale"; at 128 two overworld
portals up to 1024 blocks apart share one nether portal instead of each getting
their own. This was unreachable while the search above always failed, so it has
to land in the same change rather than after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ydw1904
ydw1904 requested a review from Snowiiii as a code owner September 3, 2026 06:25
@RoosterBooster007 RoosterBooster007 added bug Something isn't working world Chunk/region loading, world ticking & updates (stuff other than worldgen) labels Sep 3, 2026
clippy::doc_markdown, denied via clippy::pedantic in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working world Chunk/region loading, world ticking & updates (stuff other than worldgen)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants