From ea887c70194ecf38c328dfee503c58db70c950cb Mon Sep 17 00:00:00 2001 From: Tanner Date: Sun, 8 Nov 2026 17:14:22 +0000 Subject: [PATCH] Synchronize world chunk index updates --- mosfet/protocol/managers.py | 219 ++++++++++++++++++++---------------- mosfet/world.py | 16 ++- tests/test_world_updates.py | 19 ++++ 3 files changed, 154 insertions(+), 100 deletions(-) diff --git a/mosfet/protocol/managers.py b/mosfet/protocol/managers.py index 94c9c8f..1f37e28 100644 --- a/mosfet/protocol/managers.py +++ b/mosfet/protocol/managers.py @@ -1,4 +1,5 @@ import json +import threading import time from math import floor from munch import Munch @@ -218,6 +219,7 @@ class WorldState: def __init__(self, min_y=-64): self.min_y = min_y self.min_section = min_y // 16 + self.lock = threading.RLock() self.chunks = {} self.index = {} self.block_entities = {} @@ -229,68 +231,84 @@ class WorldState: self.player_id = None def set_dimension(self, dimension, dimension_type=None): - if dimension: - self.dimension = dimension - if dimension_type: - min_y = dimension_type.get('min_y', self.min_y) - if isinstance(min_y, int): - self.min_y = min_y - self.min_section = min_y // 16 + with self.lock: + if dimension: + self.dimension = dimension + if dimension_type: + min_y = dimension_type.get('min_y', self.min_y) + if isinstance(min_y, int): + self.min_y = min_y + self.min_section = min_y // 16 def load_chunk(self, packet): - # Refresh selectors in case block metadata was reloaded before login. - from mosfet.info import blocks - self.indexed_ids = blocks.INDEXED_IDS - self.unload_chunk(packet.x, packet.z) - chunk = ChunkColumn(packet.x, packet.z, packet.sections, - self.min_section) - self.chunks[(packet.x, packet.z)] = chunk - for entity in getattr(packet, 'block_entities', []): - pos = (entity['x'], entity['y'], entity['z']) - self.set_block_entity( - pos, entity['type'], entity.get('data')) - for section_y, section in chunk.sections.items(): - palette = section['blocks'] - if not self.indexed_ids.intersection( - palette.palette or [palette.get(i) for i in range(palette.size)]): - continue - for index in range(palette.size): - state_id = palette.get(index) - if state_id not in self.indexed_ids: + with self.lock: + # Refresh selectors in case block metadata was reloaded before login. + from mosfet.info import blocks + self.indexed_ids = blocks.INDEXED_IDS + self.unload_chunk(packet.x, packet.z) + chunk = ChunkColumn(packet.x, packet.z, packet.sections, + self.min_section) + self.chunks[(packet.x, packet.z)] = chunk + for entity in getattr(packet, 'block_entities', []): + pos = (entity['x'], entity['y'], entity['z']) + self.set_block_entity( + pos, entity['type'], entity.get('data')) + for section_y, section in chunk.sections.items(): + palette = section['blocks'] + if not self.indexed_ids.intersection( + palette.palette or [palette.get(i) + for i in range(palette.size)]): continue - local_x = index & 0xF - local_z = (index >> 4) & 0xF - local_y = (index >> 8) & 0xF - pos = (packet.x * 16 + local_x, - section_y * 16 + local_y, - packet.z * 16 + local_z) - self.index.setdefault(state_id, set()).add(pos) + for index in range(palette.size): + state_id = palette.get(index) + if state_id not in self.indexed_ids: + continue + local_x = index & 0xF + local_z = (index >> 4) & 0xF + local_y = (index >> 8) & 0xF + pos = (packet.x * 16 + local_x, + section_y * 16 + local_y, + packet.z * 16 + local_z) + self.index.setdefault(state_id, set()).add(pos) def handle_block_update(self, packet): self.set_block_at(packet.x, packet.y, packet.z, packet.block_state_id) def handle_section_update(self, packet): - for x, y, z, state_id in packet.changes: - self.set_block_at(x, y, z, state_id) + with self.lock: + for x, y, z, state_id in packet.changes: + self.set_block_at(x, y, z, state_id) def get_chunk(self, x, z): - return self.chunks.get((x, z)) + with self.lock: + return self.chunks.get((x, z)) def get_block_at(self, x, y, z): - chunk = self.get_chunk(floor(x / 16), floor(z / 16)) - if chunk is None: - return None - return chunk.get_block_at(floor(x), floor(y), floor(z)) + with self.lock: + chunk = self.chunks.get((floor(x / 16), floor(z / 16))) + if chunk is None: + return None + return chunk.get_block_at(floor(x), floor(y), floor(z)) + + def indexed_positions(self, block_ids): + with self.lock: + return [position for block_id in block_ids + for position in tuple(self.index.get(block_id, ()))] + + def block_entities_snapshot(self): + with self.lock: + return dict(self.block_entities) def set_block_entity(self, position, entity_type, data): position = tuple(position) - if data is None: - self.block_entities.pop(position, None) - return - self.block_entities[position] = { - 'type': entity_type, - 'data': nbt_plain(data), - } + with self.lock: + if data is None: + self.block_entities.pop(position, None) + return + self.block_entities[position] = { + 'type': entity_type, + 'data': nbt_plain(data), + } @staticmethod def _is_sign_state(block_state_id): @@ -300,67 +318,76 @@ class WorldState: def set_block_at(self, x, y, z, block_state_id): x, y, z = floor(x), floor(y), floor(z) - chunk = self.get_chunk(floor(x / 16), floor(z / 16)) - if chunk is None: - return False - previous = chunk.set_block_at(x, y, z, block_state_id) - pos = (x, y, z) - if previous in self.indexed_ids: - self.index.get(previous, set()).discard(pos) - if (self._is_sign_state(previous) and - not self._is_sign_state(block_state_id)): - self.block_entities.pop(pos, None) - if block_state_id in self.indexed_ids: - self.index.setdefault(block_state_id, set()).add(pos) - return True + with self.lock: + chunk = self.chunks.get((floor(x / 16), floor(z / 16))) + if chunk is None: + return False + previous = chunk.set_block_at(x, y, z, block_state_id) + pos = (x, y, z) + if previous in self.indexed_ids: + self.index.get(previous, set()).discard(pos) + if (self._is_sign_state(previous) and + not self._is_sign_state(block_state_id)): + self.block_entities.pop(pos, None) + if block_state_id in self.indexed_ids: + self.index.setdefault(block_state_id, set()).add(pos) + return True def unload_chunk(self, x, z): - chunk = self.chunks.pop((x, z), None) - if chunk is None: - return - x0, z0 = x * 16, z * 16 - for state_id, locations in list(self.index.items()): - self.index[state_id] = { - pos for pos in locations - if not (x0 <= pos[0] < x0 + 16 and z0 <= pos[2] < z0 + 16) + with self.lock: + chunk = self.chunks.pop((x, z), None) + if chunk is None: + return + x0, z0 = x * 16, z * 16 + for state_id, locations in list(self.index.items()): + remaining = { + pos for pos in locations.copy() + if not (x0 <= pos[0] < x0 + 16 and + z0 <= pos[2] < z0 + 16) + } + if remaining: + self.index[state_id] = remaining + else: + self.index.pop(state_id, None) + self.block_entities = { + pos: entity for pos, entity in self.block_entities.items() + if not (x0 <= pos[0] < x0 + 16 and + z0 <= pos[2] < z0 + 16) } - if not self.index[state_id]: - del self.index[state_id] - self.block_entities = { - pos: entity for pos, entity in self.block_entities.items() - if not (x0 <= pos[0] < x0 + 16 and - z0 <= pos[2] < z0 + 16) - } def unload_all_chunks(self): - self.chunks.clear() - self.index.clear() - self.block_entities.clear() + with self.lock: + self.chunks.clear() + self.index.clear() + self.block_entities.clear() def get_loaded_area(self, ignore_empty=False): - if not self.chunks: - return None - xs = [pos[0] for pos in self.chunks] - zs = [pos[1] for pos in self.chunks] - ys = [section_y for chunk in self.chunks.values() - for section_y in chunk.sections] - return ((min(xs), min(ys), min(zs)), - (max(xs), max(ys), max(zs))) + with self.lock: + if not self.chunks: + return None + xs = [pos[0] for pos in self.chunks] + zs = [pos[1] for pos in self.chunks] + ys = [section_y for chunk in self.chunks.values() + for section_y in chunk.sections] + return ((min(xs), min(ys), min(zs)), + (max(xs), max(ys), max(zs))) def check_loaded(self, chunk_distance=None): - if self.center is None: - return bool(self.chunks) - radius = self.radius if chunk_distance is None else chunk_distance - if radius is None: - return bool(self.chunks) - cx, cz = self.center - return all((x, z) in self.chunks - for x in range(cx - radius, cx + radius + 1) - for z in range(cz - radius, cz + radius + 1)) + with self.lock: + if self.center is None: + return bool(self.chunks) + radius = self.radius if chunk_distance is None else chunk_distance + if radius is None: + return bool(self.chunks) + cx, cz = self.center + return all((x, z) in self.chunks + for x in range(cx - radius, cx + radius + 1) + for z in range(cz - radius, cz + radius + 1)) @property def loaded_chunk_count(self): - return len(self.chunks) + with self.lock: + return len(self.chunks) def __repr__(self): return ('WorldState(dimension={!r}, chunks={}, min_y={}, center={!r}, ' diff --git a/mosfet/world.py b/mosfet/world.py index cff4bfa..aeb4b69 100644 --- a/mosfet/world.py +++ b/mosfet/world.py @@ -60,7 +60,11 @@ class World: def find_signs(self, center, distance=16): """Return nearby loaded signs as position/side/four-lines records.""" result = [] - for position, entity in self.g.chunks.block_entities.items(): + chunks = self.g.chunks + entities = (chunks.block_entities_snapshot() + if hasattr(chunks, 'block_entities_snapshot') else + dict(chunks.block_entities)) + for position, entity in entities.items(): if utils.phyp(center, position) > distance: continue get_block_at = getattr(self.g.chunks, 'get_block_at', None) @@ -348,9 +352,13 @@ class World: yield check def find_blocks_indexed(self, center, block_ids, distance=0): - index = [] - for bid in block_ids: - index.extend(self.g.chunks.index.get(bid, [])) + chunks = self.g.chunks + if hasattr(chunks, 'indexed_positions'): + index = chunks.indexed_positions(block_ids) + else: + index = [] + for bid in block_ids: + index.extend(chunks.index.get(bid, [])) result = [] for block in index: diff --git a/tests/test_world_updates.py b/tests/test_world_updates.py index a11b73f..5b7e6eb 100644 --- a/tests/test_world_updates.py +++ b/tests/test_world_updates.py @@ -39,6 +39,25 @@ class TestSectionBlockUpdates(unittest.TestCase): self.assertEqual(packet.id, 0x56) self.assertEqual(packet.changes, [(-173, 70, -311, bed_id)]) + def test_unload_chunk_prunes_index_and_block_entities_safely(self): + bed_id = next(iter(blocks.BED_IDS)) + inside = (1, 64, 1) + outside = (17, 64, 1) + world = WorldState() + world.chunks[(0, 0)] = ChunkColumn(0, 0, [], world.min_section) + world.index[bed_id] = {inside, outside} + world.block_entities = { + inside: {'type': 1, 'data': {}}, + outside: {'type': 1, 'data': {}}, + } + + snapshot = world.indexed_positions({bed_id}) + world.unload_chunk(0, 0) + + self.assertEqual(set(snapshot), {inside, outside}) + self.assertEqual(world.index[bed_id], {outside}) + self.assertEqual(set(world.block_entities), {outside}) + def test_section_update_replaces_old_block_and_index_entry(self): section_x, section_y, section_z = -11, 4, -20 x, y, z = -173, 70, -311