Skip to content

Prevent stale tile entities from crashing on invalid block states#557

Open
frostdev-ops wants to merge 1 commit into
LemADEC:MC1.12from
frostdev-ops:fix/stale-tile-entity-guards
Open

Prevent stale tile entities from crashing on invalid block states#557
frostdev-ops wants to merge 1 commit into
LemADEC:MC1.12from
frostdev-ops:fix/stale-tile-entity-guards

Conversation

@frostdev-ops

Copy link
Copy Markdown

Why

A saved Lift tile entity can remain in a chunk after its block state has become air. Its next tick then reads properties that air does not define and crashes the server.

Scope

  • Adds one shared block-state/property validation helper.
  • Guards only demonstrated property reads and casts in affected ticking tile entities.
  • Removes the stale instance through Chunk.EnumCreateEntityType.CHECK, so a replacement tile entity at the same position is never removed by identity mistake.
  • Cleans up biometric scanners whose initialization cannot complete.

The Ship Core guard and repository wiki page are intentionally excluded.

Evidence

  • Java 8 offline Gradle build: passed (test NO-SOURCE).
  • The cleanup path uses Forge's identity-returning CHECK lookup before invalidation/removal.
  • Merges cleanly with all five other replacement branches and both aggregate builds.

Manual testing

A live Forge regression remains: load a saved Lift tile entity over air, verify cleanup without a crash, then replace it before cleanup and verify the replacement survives.

@frostdev-ops
frostdev-ops force-pushed the fix/stale-tile-entity-guards branch from 0c9d01d to c47f6f6 Compare July 11, 2026 16:29
@LemADEC

LemADEC commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Upstream implementation removes the tile entity before removing the block itself, so the crash you've experienced is likely caused by another mod or server patch corrupting the world. Also, there's no reason only WarpDrive would be affected here, a vanilla furnace would have the same issue.
Please share the full crash report, and try to reproduce in a clean environment (very limited set of mods).

@frostdev-ops
frostdev-ops force-pushed the fix/stale-tile-entity-guards branch from c47f6f6 to c2de114 Compare July 15, 2026 02:35
@frostdev-ops

Copy link
Copy Markdown
Author

Took your advice and tried to reproduce in a clean environment (Forge 2847 + WarpDrive only, fresh dedicated server world). Learned something in the process.

You're right more than I expected. A plain "TE saved but block is now air" mismatch doesn't even crash: Chunk.addTileEntity checks hasTileEntity() against the block state at load and silently drops the orphan. I verified that by injecting a bare warpdrive:lift TE over air into the region file - server boots fine. So my original theory of a simple baked-in mismatch can't be the whole story.

But that load guard resolves the block with chunk-local masked coords (x & 15), while everything afterwards uses the TE's world position. So a TE entry whose saved position drifted one chunk over (x shifted by +16 - think hard kill mid-save, or one of those server "optimization" patches rewriting chunk data) passes the guard against the real lift block, registers, ticks, and reads air at its actual position. That crashes stock v1.5.30 on the first update interval, with the exact same trace as production:

net.minecraft.util.ReportedException: Ticking block entity
Caused by: java.lang.IllegalArgumentException: Cannot get property PropertyEnum{name=mode, clazz=class cr0s.warpdrive.data.EnumLiftMode, values=[INACTIVE, UP, DOWN, REDSTONE]} as it does not exist in BlockStateContainer{block=minecraft:air, properties=[]}
	at net.minecraft.block.state.BlockStateContainer$StateImplementation.getValue(BlockStateContainer.java:201)
	at cr0s.warpdrive.block.movement.TileEntityLift.update(TileEntityLift.java:110)
	at net.minecraft.world.World.updateEntities(World.java:1838)

Steps: fresh world, /setblock one warpdrive:lift, stop, shift the TE's x by +16 in the region file (script below), boot. Crashes every boot until you hand-edit the region back.

With this PR it's a single warn line (Invalidating orphaned tile entity TileEntityLift @ (252 200 248), found block state minecraft:air) and the server stays up. One caveat I want to be upfront about: for this cross-chunk case the warn comes back on every boot, because vanilla's invalid-TE sweep calls chunk.removeTileEntity(te.getPos()) which resolves to the wrong chunk, so the stale map entry survives the next save. The plain same-chunk cases heal permanently. Contained rather than erased, but the world is playable instead of boot-looping.

On the furnace comparison: an idle furnace only touches its block state on burn transitions, so an orphaned one ticks harmlessly. Most of our TEs read block properties every tick (lift reads mode, air gen reads ACTIVE/FACING, ...), which is why WarpDrive is disproportionately exposed to this class of corruption compared to vanilla.

I'll attach the production crash report once I'm back on that network, but the repro above is mod-clean. And agreed - no claim that WarpDrive causes the mismatch, this only survives the symptom.

region corruption script (python, nbtlib)
"""Corrupt a saved warpdrive:lift tile entity by shifting its stored position
one chunk over (+16 on x). The chunk-load guard masks x&15, so the entry still
matches the real lift block and registers - but its world position resolves to
air in the neighboring chunk, reproducing the production 'ticking TE without
its block' crash from a corruption baked into the region file.

Usage: python corrupt_lift_te_v2.py <world_dir> <x> <y> <z>
"""
import io
import sys
import zlib
import gzip
import struct
from pathlib import Path

import nbtlib
from nbtlib import Int

world = Path(sys.argv[1])
te_x, te_y, te_z = int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])
chunk_x, chunk_z = te_x >> 4, te_z >> 4
region_path = world / "region" / f"r.{chunk_x >> 5}.{chunk_z >> 5}.mca"

raw = bytearray(region_path.read_bytes())
index = ((chunk_x & 31) + (chunk_z & 31) * 32) * 4
entry = struct.unpack(">I", raw[index:index + 4])[0]
offset_sectors, count_sectors = entry >> 8, entry & 0xFF
assert offset_sectors > 0, "chunk not present"

pos = offset_sectors * 4096
length, compression = struct.unpack(">IB", raw[pos:pos + 5])
payload = bytes(raw[pos + 5:pos + 4 + length])
decompressed = zlib.decompress(payload) if compression == 2 else gzip.decompress(payload)

chunk_nbt = nbtlib.File.parse(io.BytesIO(decompressed))
chunk_root = chunk_nbt[""] if "" in chunk_nbt else chunk_nbt
tile_entities = chunk_root["Level"]["TileEntities"]

found = False
for te in tile_entities:
    if str(te["id"]) == "warpdrive:lift" and int(te["x"]) == te_x and int(te["y"]) == te_y and int(te["z"]) == te_z:
        te["x"] = Int(te_x + 16)
        found = True
        print(f"lift TE found in chunk ({chunk_x},{chunk_z}); stored position shifted to ({te_x + 16},{te_y},{te_z})")
        break
assert found, "lift TE not found in chunk"

buffer = io.BytesIO()
chunk_nbt.write(buffer)
recompressed = zlib.compress(buffer.getvalue())
needed_sectors = (len(recompressed) + 5 + 4095) // 4096
new_block = struct.pack(">IB", len(recompressed) + 1, 2) + recompressed
if needed_sectors <= count_sectors:
    raw[pos:pos + len(new_block)] = new_block
else:
    new_offset = (len(raw) + 4095) // 4096
    raw.extend(b"\0" * (new_offset * 4096 - len(raw)))
    raw.extend(new_block)
    raw.extend(b"\0" * (needed_sectors * 4096 - len(new_block)))
    raw[index:index + 4] = struct.pack(">I", (new_offset << 8) | needed_sectors)
region_path.write_bytes(bytes(raw))
print("region file updated OK")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants