Skip to content

Stop reporting tile entities the world already dropped - #201

Merged
EverNife merged 1 commit into
CrucibleMC:stagingfrom
EverNife:fix/tile-entities-in-range
Sep 1, 2026
Merged

Stop reporting tile entities the world already dropped#201
EverNife merged 1 commit into
CrucibleMC:stagingfrom
EverNife:fix/tile-entities-in-range

Conversation

@EverNife

Copy link
Copy Markdown
Member

Problem

Vanilla read the world's own tile entity list, which its tick sweeps clean, so neither of these
readers could return a dead entry or miss a live one. Forge changed the source to
Chunk.chunkTileEntityMap for the chunk-scoped lookup and compensated on both counts: an inclusive
maximum corner, and a !isInvalid() guard backed by a Chunk.removeInvalidTileEntity it had to
add, called only from World.updateEntities. Cauldron re-ported CraftBukkit's version over Forge's
and kept the new data source without either compensation. That is what this tree inherited — traced
to the initial Thermos import, never a deliberate decision here.

Both losses are real:

  • Bounds. WorldServer.func_147486_a treats the maximum corner as exclusive while every caller
    compiled against Forge passes an inclusive one. A caller asking for a whole chunk silently misses
    the last block row and column — 31 of 256 columns. The method has no caller left in this tree,
    but mods call it.
  • Validity. The sweep that reconciles the chunk map is the Spigot time-boxed loop, with a
    rolling cursor and a max-tick-time.tile budget, so under load it does not finish in a tick and
    an invalidated entry stays visible for an unbounded number of them.

Change

func_147486_a takes the maximum corner as inclusive again and skips invalidated entries.
EntityPlayerMP does the same filtering where it collects a chunk's tile entities to send — it
feeds them straight to getDescriptionPacket, which is mod code running on an object the world
considers dead.

Verification

A HeadlessMC Forge 1.7.10 client joined a modded Crucible (15 mods, CustomNPC-Plus among them) and
streamed chunks for real. 60 CustomNPCs blocks that carry a tile entity were placed around the
player — npcWaypoint, npcBorder, npcLampUnlit, npcCarpentyBench, npcChair, npcTombstone,
npcBarrel, npcRedstoneBlock, npcCampfireUnlit, npcCandleUnlit, npcSign and others, drawn
at random from the 31 registered types.

Each was then invalidated the way a mod does on a state change: invalidate() only, block and chunk
map entry left alone. The chunks were re-queued in the same breath, so the send happens while the
dead entries are still in chunkTileEntityMap — the exact window the guard exists for. The filter
was toggled at runtime between the two runs, so both sides saw the same server, the same world and
the same kind of tile entity:

fixed     [ChunkSend] chunks=49 packets=10 tiles=0  skippedInvalid=62 sentInvalid=0
pre-fix   [ChunkSend] chunks=50 packets=10 tiles=46 skippedInvalid=0  sentInvalid=46

Without the guard, 46 tile entities the world had already dropped had getDescriptionPacket()
called on them and were shipped to the client. With it, none were: tiles=0, and the 62 dead
entries were skipped. None of the CustomNPCs types threw on the call, so the harm here is stale
state reaching the client rather than a crash — but the packet is built from mod code running on an
invalidated object, and what that returns is the mod's business, not something this path can assume.

The bounds half has no runtime coverage: nothing in this tree calls func_147486_a any more, so
that change is argued from the Forge sources it realigns with, not demonstrated by a test.

@EverNife
EverNife force-pushed the fix/tile-entities-in-range branch from 0c47bed to 1f2e244 Compare August 28, 2026 23:16
Vanilla read the world's own tile entity list, which its tick sweeps clean, so
neither of these readers could return a dead entry or miss a live one. Forge
changed the source to Chunk.chunkTileEntityMap for the chunk-scoped lookup and
compensated on both counts: an inclusive maximum corner, and a !isInvalid()
guard backed by a Chunk.removeInvalidTileEntity it had to add, called only from
World.updateEntities. Cauldron re-ported CraftBukkit's version over Forge's and
kept the new data source without either compensation; that is what this tree
inherited.

Both losses are real here. The maximum corner is exclusive while the callers
compiled against Forge pass an inclusive one, so a caller asking for a whole
chunk silently misses the last block row and column - 31 of 256 columns. And
the sweep that reconciles the chunk map is the Spigot time boxed loop, with a
rolling cursor and a max-tick-time.tile budget: an entry invalidated after the
cursor has already passed it stays visible for the rest of the tick whatever
the load, and for an unbounded number of ticks once the budget runs out.

WorldServer.func_147486_a takes the maximum corner as inclusive again and skips
invalidated entries. EntityPlayerMP does the same filtering where it collects a
chunk's tile entities to send: it feeds them straight to getDescriptionPacket,
which is mod code running on an object the world considers dead.

Measured on a modded server with a headless Forge 1.7.10 client, 60 CustomNPCs
blocks placed and then invalidated the way a mod does on a state change, the
filter toggled at runtime between the two sends: with the guard, tiles=0 and 62
dead entries skipped; without it, 46 of them had getDescriptionPacket called and
went to the client.
@EverNife
EverNife force-pushed the fix/tile-entities-in-range branch from 1f2e244 to 61f2df6 Compare September 1, 2026 17:56
@EverNife
EverNife merged commit 399403a into CrucibleMC:staging Sep 1, 2026
1 check failed
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