Consolidate bot runtime on Minecraft 26.3

This commit is contained in:
Tanner
2026-11-04 03:05:23 +00:00
parent b6f7ce9948
commit f7f58f5d07
15 changed files with 706 additions and 537 deletions
-77
View File
@@ -1,77 +0,0 @@
"""Safe, movement-only bridge from legacy navigation to protocol 777."""
import threading
import time
import traceback
def start(global_state):
"""Start the legacy path-following physics, without legacy job/game packets."""
g = global_state
g.protocol_version = 777
g.time = 0.0
g.path = []
g.look_at = None
g.y_v = 0.0
g.y_a = 0.0
g.crawling = False
g.afk_timeout = 0.0
g.objects = g.get('objects') or {}
g.players = g.get('players') or {}
g.breaking = None
g.break_time = 0.0
g.correction_count = g.get('correction_count', 0)
g.yaw = float(g.get('yaw', 0.0) or 0.0)
g.pitch = float(g.get('pitch', 0.0) or 0.0)
g.chunks.loading = False
# Local navigation only needs nearby chunks; waiting for the entire view
# radius can stall on servers that stream chunks gradually.
g.physics_render_distance = 2
g.movement_paused = False
g.movement_status = 'idle'
g.movement_error = None
# Inventory, interaction, and job packets remain disabled until migrated.
g.job = None
g.game = None
def run():
from mosfet import bot, utils
loaded_since = None
while (g.get('running', True) and g.get('connection') and
g.connection.connected):
started = time.monotonic()
now = time.monotonic()
if g.chunks.check_loaded(g.physics_render_distance):
if loaded_since is None:
loaded_since = now
else:
loaded_since = None
# Let the initial position correction and first chunk batch settle
# before allowing any client-originated movement.
movement_ready = loaded_since is not None and now - loaded_since >= 0.5
if g.get('movement_paused'):
g.movement_status = 'paused'
elif g.path and not movement_ready:
g.movement_status = 'waiting_for_chunks'
elif g.path and movement_ready:
g.movement_status = 'following_path'
try:
bot.tick(g)
except Exception:
traceback.print_exc()
g.movement_error = traceback.format_exc()
g.path = []
g.movement_status = 'error'
print('[26.3 movement] stopped after navigation error',
flush=True)
return
elif not g.path and not g.get('movement_paused'):
g.movement_status = 'idle'
delay = max(0.0, utils.TICK - (time.monotonic() - started))
time.sleep(delay)
worker = threading.Thread(target=run, name='mosfet-26.3-movement',
daemon=True)
g.movement_thread = worker
worker.start()
return worker
+133 -27
View File
@@ -6,6 +6,8 @@ import os
import sys
import time
import importlib
import threading
import traceback
from math import floor, ceil
from copy import copy
@@ -68,26 +70,51 @@ def tick(global_state):
required_distance = getattr(g, 'physics_render_distance',
g.info.render_distance)
if not g.chunks.check_loaded(required_distance):
if g.get('movement_status') != 'waiting_for_chunks':
print('[movement] status=waiting_for_chunks near {}'.format(
utils.pint(p)), flush=True)
g.movement_status = 'waiting_for_chunks'
if not g.chunks.loading:
print('Loading chunks', end='', flush=True)
g.chunks.loading = time.time()
if getattr(g, 'protocol_version', 0) == 777:
from minecraft.networking.modern import PlayerPositionRotation
packet = PlayerPositionRotation(
x=p.x, y=p.y, z=p.z, pitch=0, yaw=0, on_ground=True)
else:
packet = serverbound.play.PositionAndLookPacket(
x=p.x, feet_y=p.y, z=p.z, pitch=0, yaw=0, on_ground=True)
now = time.time()
if now - g.get('chunk_wait_report_at', 0) >= 5:
center = g.chunks.center
if center is None:
missing = 'cache center not received'
else:
cx, cz = center
missing = [
(x, z)
for x in range(cx - required_distance,
cx + required_distance + 1)
for z in range(cz - required_distance,
cz + required_distance + 1)
if (x, z) not in g.chunks.chunks
]
print('\n[movement] chunk wait: pos={}, center={}, '
'radius={}, loaded={}, missing={}'.format(
utils.pint(p), center, g.chunks.radius,
len(g.chunks.chunks), missing), flush=True)
g.chunk_wait_report_at = now
from minecraft.networking.modern import (
ClientTickEnd, PlayerPositionRotation,
)
packet = PlayerPositionRotation(
x=p.x, y=p.y, z=p.z, pitch=0, yaw=0, on_ground=True)
g.connection.write_packet(packet, force=True)
if getattr(g, 'protocol_version', 0) == 777:
from minecraft.networking.modern import ClientTickEnd
g.connection.write_packet(ClientTickEnd(), force=True)
g.connection.write_packet(ClientTickEnd(), force=True)
return
else:
if g.chunks.loading:
print()
print('Chunks loaded in', round(time.time() - g.chunks.loading, 2), 's')
g.chunks.loading = False
g.chunk_wait_report_at = 0
status = 'following_path' if g.path else 'running_job'
if g.get('movement_status') != status:
print('[movement] status={}'.format(status), flush=True)
g.movement_status = status
########## object physics ##########
# note: it's possible the chunk data is out of date when this runs
@@ -208,23 +235,15 @@ def tick(global_state):
target_pitch_d = 0 - g.pitch
g.pitch += utils.cap(target_pitch_d, 10)
if getattr(g, 'protocol_version', 0) == 777:
from minecraft.networking.modern import PlayerPositionRotation
packet = PlayerPositionRotation(
x=p.x, y=p.y, z=p.z, pitch=g.pitch, yaw=g.yaw,
on_ground=(not in_air))
else:
packet = serverbound.play.PositionAndLookPacket(
x=p.x, feet_y=p.y, z=p.z, pitch=g.pitch, yaw=g.yaw,
on_ground=(not in_air))
if getattr(g, 'protocol_version', 0) == 777:
from minecraft.networking.modern import ClientTickEnd
# Keep the client-tick boundary after its movement packet on the wire;
# force=True on only the latter could overtake the queued position.
g.connection.write_packet(packet, force=True)
g.connection.write_packet(ClientTickEnd(), force=True)
else:
g.connection.write_packet(packet)
from minecraft.networking.modern import (
ClientTickEnd, PlayerPositionRotation,
)
packet = PlayerPositionRotation(
x=p.x, y=p.y, z=p.z, pitch=g.pitch, yaw=g.yaw,
on_ground=(not in_air))
# Keep the client-tick boundary after its movement packet on the wire.
g.connection.write_packet(packet, force=True)
g.connection.write_packet(ClientTickEnd(), force=True)
if g.job:
g.job.tick()
@@ -352,3 +371,90 @@ def bot(global_state):
g.connection.early_outgoing_packet_listeners = []
print('Bot module loaded.')
def start_automation(global_state):
"""Start migrated path-following and crop automation only."""
g = global_state
g.protocol_version = 777
g.time = 0.0
g.path = []
g.look_at = None
g.y_v = 0.0
g.y_a = 0.0
g.crawling = False
g.afk_timeout = 0.0
g.objects = g.get('objects') or {}
g.players = g.get('players') or {}
g.breaking = None
g.break_time = 0.0
g.correction_count = g.get('correction_count', 0)
g.yaw = float(g.get('yaw', 0.0) or 0.0)
g.pitch = float(g.get('pitch', 0.0) or 0.0)
g.chunks.loading = False
# Local navigation only needs nearby chunks; waiting for the entire view
# radius can stall on servers that stream chunks gradually.
g.physics_render_distance = 2
g.movement_paused = False
g.movement_status = 'idle'
g.movement_error = None
# Keep the port constrained to crop harvesting; other legacy jobs and
# general inventory/window mutations are not exposed through this adapter.
from mosfet.game import Game
from mosfet.job import JobStates
g.game = Game(g)
g.job = JobStates(g)
def run():
from mosfet import bot, utils
loaded_since = None
initial_world_ready = False
def set_status(status):
if g.movement_status != status:
print('[movement] status={}'.format(status), flush=True)
g.movement_status = status
while (g.get('running', True) and g.get('connection') and
g.connection.connected):
started = time.monotonic()
now = time.monotonic()
if not initial_world_ready:
if g.chunks.check_loaded(g.physics_render_distance):
if loaded_since is None:
loaded_since = now
elif now - loaded_since >= 0.5:
initial_world_ready = True
else:
loaded_since = None
# Wait once for the initial position/chunk batch. After that,
# bot.tick() keeps requesting chunks at cache boundaries instead
# of gating itself forever on a momentary missing column.
movement_ready = initial_world_ready
job_active = (g.job is not None and g.job.state != g.job.idle)
has_work = bool(g.path) or job_active or bool(g.breaking)
if g.get('movement_paused'):
set_status('paused')
elif has_work and not movement_ready:
set_status('waiting_for_chunks')
elif has_work and movement_ready:
try:
bot.tick(g)
except Exception:
traceback.print_exc()
g.movement_error = traceback.format_exc()
g.path = []
g.movement_status = 'error'
print('[movement] stopped after navigation error',
flush=True)
return
elif not has_work and not g.get('movement_paused'):
set_status('idle')
delay = max(0.0, utils.TICK - (time.monotonic() - started))
time.sleep(delay)
worker = threading.Thread(target=run, name='mosfet-movement',
daemon=True)
g.movement_thread = worker
worker.start()
return worker
+22 -16
View File
@@ -10,7 +10,6 @@ from mosfet.protocol.types import Slot
from mosfet import print_help
from mosfet import utils
from mosfet import path
from mosfet import bot
from mosfet.info import blocks
from mosfet.info import items
from mosfet.info import mobs
@@ -56,6 +55,20 @@ class Commands:
if data.startswith('[') and data.endswith(']'):
command = 'nosquarebrackets'
unsupported = {
'gather', 'loiter', 'trade', 'gapple', 'cache', 'fill',
'drop', 'dump', 'drain', 'break', 'open', 'click', 'use',
'interact', 'respawn',
}
if (command in unsupported or
(command == 'farm' and not data.lower().startswith('crop'))):
reply = 'not migrated to Minecraft 26.3 yet'
print('Reply:', reply)
if private:
self.g.chat.send('/r ' + reply)
else:
self.g.chat.send(reply)
return
try:
@@ -406,15 +419,12 @@ class Commands:
self.g.job.state = self.g.job.farm_wart
reply = 'ok'
elif data.startswith('crop'):
self.g.job.state = self.g.job.farm_crop
reply = 'ok'
if reply and self.g.dimension == 'overworld':
for i in self.g.inv.values():
if i.item_id in items.BED_IDS:
break
mode = self.g.get('game_mode')
if mode is not None and mode != 0:
reply = 'crop farming needs Survival mode'
else:
reply += ', I need a bed'
self.g.job.state = self.g.job.farm_crop
reply = 'ok'
## 1loiter - stands still but eats, sleeps, and flees
if command == 'loiter':
@@ -428,8 +438,8 @@ class Commands:
## 1stop - stops the current job and resets bot
if command == 'stop':
self.g.job.stop()
self.g.game.close_window()
bot.init(self.g)
reply = 'ok'
## 1drop - drops the current stack its holding
@@ -515,14 +525,12 @@ class Commands:
navpath = self.g.world.path_to_place(pos, goal)
if navpath:
self.g.path = navpath
if self.g.job:
self.g.job.stop()
self.g.path = navpath
print(len(navpath))
print(navpath)
print(round(time.time() - start, 3), 'seconds')
if self.g.job:
self.g.job.stop()
self.g.look_at = None
reply = 'ok'
else:
@@ -543,14 +551,12 @@ class Commands:
navpath = self.g.world.path_to_place(pos, goal)
if navpath:
self.g.path = navpath
if self.g.job:
self.g.job.stop()
self.g.path = navpath
print(len(navpath))
print(navpath)
print(round(time.time() - start, 3), 'seconds')
if self.g.job:
self.g.job.stop()
self.g.look_at = None
reply = 'ok'
else:
+13 -8
View File
@@ -6,10 +6,10 @@ import os
import threading
from minecraft.networking.connection import Connection
from minecraft.networking.modern import (
CancelPostjoinDialog, ChatCommand, PlayShowDialog,
CancelPostjoinDialog, ChatCommand, PlayGameEventPacket, PlayShowDialog,
PlayLogin, SubmitPostjoinLoginDialog, SystemChat, TeleportPosition,
)
from mosfet.world_26_3 import WorldState
from mosfet.protocol.managers import WorldState
from mosfet.vector import Point3D
@@ -71,6 +71,7 @@ def main(global_state=None):
global_state.connected = True
global_state.connection_status = 'in_game'
global_state.player_id = packet.player_id
global_state.game_mode = packet.game_mode
global_state.dimension = packet.dimension.removeprefix('minecraft:')
packet.render_distance = packet.chunk_radius
global_state.info = packet
@@ -79,15 +80,14 @@ def main(global_state=None):
from mosfet.world import World
from mosfet.commands import Commands
from mosfet.protocol.modern import ModernChatManager
from mosfet.protocol.entities_26_3 import ModernEntityManager
from mosfet.protocol.inventory_26_3 import ModernInventoryManager
from mosfet.automation_26_3 import start as start_modern_movement
from mosfet.protocol.managers import EntityManager, InventoryManager
from mosfet.bot import start_automation
global_state.world = World(global_state)
global_state.chat = ModernChatManager(global_state)
global_state.entities = ModernEntityManager(global_state)
global_state.inventory = ModernInventoryManager(global_state)
global_state.entities = EntityManager(global_state)
global_state.inventory = InventoryManager(global_state)
global_state.commands = Commands(global_state)
start_modern_movement(global_state)
start_automation(global_state)
def send_auth_command(action):
@@ -100,6 +100,11 @@ def main(global_state=None):
connection.write_packet(ChatCommand(command=command.lstrip('/')),
force=True)
@connection.listener(PlayGameEventPacket)
def on_game_event(packet):
if global_state is not None and packet.event == 3:
global_state.game_mode = int(packet.value)
@connection.listener(TeleportPosition)
def on_spawn(packet):
if global_state is not None:
+117 -1
View File
@@ -6,6 +6,10 @@ from munch import Munch
from minecraft.networking.packets import Packet, clientbound, serverbound
from minecraft.networking.types import BlockFace
from minecraft.networking.modern import (
ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket,
SetCarriedItemPacket, UseItemOnBlockPacket,
)
from mosfet.protocol.packets import (
SetSlotPacket, PlayerDiggingPacket,
@@ -29,7 +33,7 @@ from mosfet.info import items
from mosfet.info import mcdata
from mosfet.info import mobs
class Game:
class LegacyGame:
def __init__(self, global_state):
self.g = global_state
@@ -568,3 +572,115 @@ class Game:
if not self.g.path:
self.g.correction_count = 0
# Active game facade. The constructor registers no legacy packet listeners;
# migrated actions use the 26.3 packet classes imported above.
class Game(LegacyGame):
def __init__(self, global_state):
self.g = global_state
self.sequence = 0
self.break_deadline = None
self.breaking_state = None
def _send_action(self, packet):
self.g.connection.write_packet(packet, force=True)
self.g.connection.write_packet(ClientTickEnd(), force=True)
def _next_sequence(self):
self.sequence += 1
return self.sequence
def break_block(self, location):
location = tuple(int(value) for value in location)
block_id = self.g.chunks.get_block_at(*location)
if block_id is None or block_id == blocks.AIR:
return False
packet = PlayerActionPacket(
action=0, location=location, face=1,
sequence=self._next_sequence())
self._send_action(packet)
self.g.breaking = location
self.breaking_state = block_id
hardness_time = utils.break_time(block_id, self.g.get('holding', 0))
self.break_deadline = time.monotonic() + max(0.05, hardness_time)
return True
def tick(self):
if not self.g.get('breaking') or self.break_deadline is None:
return
if time.monotonic() < self.break_deadline:
return
packet = PlayerActionPacket(
action=3, location=self.g.breaking, face=1,
sequence=self._next_sequence())
self._send_action(packet)
location = self.g.breaking
if self.g.chunks.get_block_at(*location) == self.breaking_state:
# Predict the normal client-side block removal. The server's later
# block update is authoritative and will correct the cache if the
# action was denied or the block changed differently.
self.g.chunks.set_block_at(*location, blocks.AIR)
self.g.breaking = None
self.break_deadline = None
self.breaking_state = None
def cancel_break(self):
if not self.g.get('breaking'):
return
packet = PlayerActionPacket(
action=2, location=self.g.breaking, face=1,
sequence=self._next_sequence())
self._send_action(packet)
self.g.breaking = None
self.break_deadline = None
self.breaking_state = None
def select_item(self, item_ids):
item_ids = set(item_ids)
matches = [(slot, item) for slot, item in self.g.inv.items()
if item.present and item.item_id in item_ids]
if not matches:
return False
# Prefer an already-hotbar stack; otherwise swap the requested stack
# into an empty hotbar slot using the modern container SWAP input.
slot, item = next(
((slot, item) for slot, item in matches if 36 <= slot <= 44),
matches[0])
if 36 <= slot <= 44:
hotbar_slot = slot - 36
elif 9 <= slot <= 35:
empty_hotbar = next(
(candidate - 36 for candidate in range(36, 45)
if candidate not in self.g.inv or
not self.g.inv[candidate].present), None)
hotbar_slot = (empty_hotbar if empty_hotbar is not None else
self.g.get('holding_slot', 0) or 0)
self.g.connection.write_packet(ContainerClickSwapPacket(
container_id=0,
state_id=self.g.inventory_state_ids.get(0, 0),
slot=slot, button=hotbar_slot), force=True)
else:
return False
if self.g.get('holding_slot') != hotbar_slot:
self.g.connection.write_packet(SetCarriedItemPacket(
slot=hotbar_slot), force=True)
self.g.holding_slot = hotbar_slot
self.g.holding = item.item_id
self.g.connection.write_packet(ClientTickEnd(), force=True)
return True
def place_block(self, location, face):
location = tuple(int(value) for value in location)
packet = UseItemOnBlockPacket(
hand=0, location=location, face=int(face),
cursor_x=0.5, cursor_y=0.99 if int(face) == 1 else 0.5,
cursor_z=0.5, inside_block=False, world_border_hit=False,
sequence=self._next_sequence())
self._send_action(packet)
def close_window(self):
# Container open/close packet migration is not part of the crop path.
return None
+3 -1
View File
@@ -77,7 +77,9 @@ LEAVES = sorted(name for name in MCD_BLOCKS if name.endswith('_leaves'))
SAPLINGS = sorted(name for name in MCD_BLOCKS
if name.endswith('_sapling') or name.endswith('_propagule'))
BEDS = sorted(name for name in MCD_BLOCKS if name.endswith('_bed'))
INDEXED = ['chest', 'trapped_chest', 'emerald_block', 'barrel'] + BEDS
INDEXED = ['chest', 'trapped_chest', 'emerald_block', 'barrel'] + BEDS + [
'wheat', 'potatoes', 'carrots', 'beetroots',
]
LOG_IDS = get_set(LOGS)
LEAF_IDS = get_set(LEAVES)
+20 -1
View File
@@ -55,7 +55,7 @@ for module in [
importlib.reload(module)
class JobStates:
class LegacyJobStates:
def idle(self):
return []
@@ -262,3 +262,22 @@ class JobStates:
def tick(self):
self.run_machines(self.state())
class JobStates(LegacyJobStates):
"""26.3 job facade; crop gathering is the currently migrated job."""
def __init__(self, global_state):
self.g = global_state
self.gather_crop_states = gather_crop.GatherCropStates(global_state)
self.state = self.idle
def farm_crop(self):
return [self.gather_crop_states]
def stop(self):
if self.g.get('breaking'):
self.g.game.cancel_break()
self.g.path = []
self.g.look_at = None
self.gather_crop_states.state = self.gather_crop_states.idle
self.state = self.idle
+64 -10
View File
@@ -35,12 +35,15 @@ class GatherCropStates:
blocks.MATURE_BEETROOT_ID,
]
for crop in w.find_blocks_3d(p, mature_crops, 50, 20, True):
candidates = w.find_blocks_indexed(p, mature_crops, 50)
for crop in candidates:
if crop not in self.bad_crops:
break
else: # for
print('No good crops left, aborting.')
self.state = self.cleanup
print('No good crops left; retrying after a short wait.')
self.retry_after = time.monotonic() + 15.0
self.state = self.wait_for_crops
return
print('Found crop:', crop)
@@ -48,6 +51,10 @@ class GatherCropStates:
self.type_id = w.block_at(*crop)
self.state = self.nav_to_crop
def wait_for_crops(self):
if time.monotonic() >= self.retry_after:
self.state = self.find_new_crop
def nav_to_crop(self):
w = self.g.world
p = utils.pint(self.g.pos)
@@ -71,8 +78,34 @@ class GatherCropStates:
self.state = self.break_crop
def break_crop(self):
self.g.game.break_block(self.crop)
self.state = self.select_seed
print('Requesting crop break at {}: block={}, mode={}, held={}'.format(
self.crop, self.g.world.block_at(*self.crop),
self.g.get('game_mode'), self.g.get('holding')))
if self.g.game.break_block(self.crop):
print('Started breaking crop {} (state {})'.format(
self.crop, self.type_id))
self.break_deadline = time.time() + 5.0
self.state = self.wait_break
else:
print('Break request skipped; current block is',
self.g.world.block_at(*self.crop))
self.bad_crops.append(self.crop)
self.state = self.find_new_crop
def wait_break(self):
current_state = self.g.world.block_at(*self.crop)
if current_state != self.type_id:
print('Crop break confirmed at {}: {} -> {}'.format(
self.crop, self.type_id, current_state))
self.seed_deadline = time.monotonic() + 8.0
self.seed_wait_logged = False
self.state = self.select_seed
elif time.time() > self.break_deadline:
print('Crop break timed out at {}: expected state {}, server still has {}'.format(
self.crop, self.type_id, current_state))
print('Crop did not break; blacklisting', self.crop)
self.bad_crops.append(self.crop)
self.state = self.find_new_crop
def select_seed(self):
p = utils.pint(self.g.pos)
@@ -86,9 +119,19 @@ class GatherCropStates:
self.target_seed = crop_seeds[self.type_id]
if self.g.game.select_item([self.target_seed]):
self.seed_deadline = None
self.state = self.wait_select
else:
print('Havent picked up seed yet')
if (self.seed_deadline is not None and
time.monotonic() > self.seed_deadline):
print('No seed collected for crop; skipping', self.crop)
self.bad_crops.append(self.crop)
self.seed_deadline = None
self.state = self.find_new_crop
elif not getattr(self, 'seed_wait_logged', False):
print('Waiting for crop seed {} in inventory/hotbar'.format(
self.target_seed))
self.seed_wait_logged = True
return
def wait_select(self):
@@ -98,17 +141,25 @@ class GatherCropStates:
self.state = self.place_crop
def place_crop(self):
p = utils.pint(self.g.pos)
p = utils.padd(self.crop, path.BLOCK_BELOW)
self.place_deadline = time.time() + 3.0
self.g.game.place_block(p, BlockFace.TOP)
print('Placed crop')
self.state = self.wait_place
def wait_place(self):
w = self.g.world
if w.block_at(*self.crop) == blocks.AIR:
return
else:
current_state = w.block_at(*self.crop)
crop_name = blocks.BLOCKS.get(self.type_id)
replanted = (current_state not in (None, blocks.AIR) and
blocks.BLOCKS.get(current_state) == crop_name and
current_state != self.type_id)
if replanted:
self.state = self.cleanup
elif time.time() > getattr(self, 'place_deadline', 0):
print('Crop did not replant; blacklisting', self.crop)
self.bad_crops.append(self.crop)
self.state = self.find_new_crop
def cleanup(self):
self.g.look_at = None
@@ -125,6 +176,9 @@ class GatherCropStates:
self.crop = None
self.type_id = None
self.target_seed = None
self.seed_deadline = None
self.seed_wait_logged = False
self.retry_after = 0.0
self.bad_crops = []
def run(self):
-102
View File
@@ -1,102 +0,0 @@
"""Entity and player-list updates for the 26.3 play protocol."""
from munch import Munch
from minecraft.networking.modern import (
AddEntityPacket, EntityPositionSyncPacket, MoveEntityPositionPacket,
MoveEntityPositionRotationPacket, MoveEntityRotationPacket,
PlayerInfoRemovePacket, PlayerInfoUpdatePacket, RemoveEntitiesPacket,
)
from mosfet.info import mobs
class ModernEntityManager:
def __init__(self, global_state):
self.g = global_state
self.g.mobs = self.g.get('mobs') or {}
self.g.players = self.g.get('players') or {}
self.g.objects = self.g.get('objects') or {}
self.g.player_names = self.g.get('player_names') or {}
register = self.g.connection.register_packet_listener
for packet_type, handler in (
(AddEntityPacket, self.handle_add_entity),
(RemoveEntitiesPacket, self.handle_remove_entities),
(MoveEntityPositionPacket, self.handle_move),
(MoveEntityPositionRotationPacket, self.handle_move),
(MoveEntityRotationPacket, self.handle_rotation),
(EntityPositionSyncPacket, self.handle_position_sync),
(PlayerInfoUpdatePacket, self.handle_player_info),
(PlayerInfoRemovePacket, self.handle_player_info_remove),
):
register(handler, packet_type)
def handle_add_entity(self, packet):
name = mobs.MOB_NAMES.get(packet.entity_type_id, 'unknown')
common = dict(entity_id=packet.entity_id, x=packet.x,
y=packet.y, z=packet.z)
if name == 'player':
player_uuid = str(packet.entity_uuid)
self.g.players[packet.entity_id] = Munch(
**common, player_uuid=player_uuid,
yaw=packet.yaw, pitch=packet.pitch)
elif name == 'item':
self.g.objects[packet.entity_id] = Munch(
**common, entity_uuid=str(packet.entity_uuid),
velocity_x=packet.velocity_x * 8000,
velocity_y=packet.velocity_y * 8000,
velocity_z=packet.velocity_z * 8000)
else:
self.g.mobs[packet.entity_id] = Munch(
**common, entity_uuid=str(packet.entity_uuid),
type=packet.entity_type_id, yaw=packet.yaw,
pitch=packet.pitch)
def _entity(self, entity_id):
for collection in (self.g.players, self.g.mobs, self.g.objects):
entity = collection.get(entity_id)
if entity is not None:
return entity
return None
def handle_move(self, packet):
entity = self._entity(packet.entity_id)
if entity is None:
return
entity.x += packet.delta_x
entity.y += packet.delta_y
entity.z += packet.delta_z
if hasattr(packet, 'yaw'):
entity.yaw = packet.yaw
entity.pitch = packet.pitch
def handle_rotation(self, packet):
entity = self._entity(packet.entity_id)
if entity is not None:
entity.yaw = packet.yaw
entity.pitch = packet.pitch
def handle_position_sync(self, packet):
entity = self._entity(packet.entity_id)
if entity is not None:
entity.x, entity.y, entity.z = packet.x, packet.y, packet.z
entity.yaw = packet.yaw
entity.pitch = packet.pitch
def handle_remove_entities(self, packet):
for entity_id in packet.entity_ids:
self.g.players.pop(entity_id, None)
self.g.mobs.pop(entity_id, None)
self.g.objects.pop(entity_id, None)
def handle_player_info(self, packet):
for profile_uuid, name in packet.entries:
if name:
uuid_text = str(profile_uuid)
self.g.player_names[uuid_text] = name
self.g.player_names[name] = profile_uuid
self.g.player_names[name.lower()] = profile_uuid
def handle_player_info_remove(self, packet):
removed = {str(uuid) for uuid in packet.uuids}
for key, value in list(self.g.player_names.items()):
if str(value) in removed or str(key) in removed:
del self.g.player_names[key]
-55
View File
@@ -1,55 +0,0 @@
"""Minimal 26.3 inventory synchronization for legacy read-only commands."""
from mosfet.protocol.types import Slot
from minecraft.networking.modern import (
ContainerSetContentPacket, ContainerSetSlotPacket, SetCursorItemPacket,
)
class ModernInventoryManager:
def __init__(self, global_state):
self.g = global_state
self.g.inv = self.g.get('inv') or {}
self.g.inventory_state_ids = self.g.get('inventory_state_ids') or {}
self.g.inventory_partial = False
self.g.cursor = Slot(present=False)
register = self.g.connection.register_packet_listener
register(self.handle_content, ContainerSetContentPacket)
register(self.handle_slot, ContainerSetSlotPacket)
register(self.handle_cursor, SetCursorItemPacket)
@staticmethod
def to_slot(item):
return Slot(item.present, item.item_id, item.item_count,
components=item.components,
component_ids=item.component_ids,
removed_components=item.removed_components,
components_unknown=item.components_unknown)
def handle_content(self, packet):
slots = {index: self.to_slot(item)
for index, item in enumerate(packet.items)}
self.g.inventory_state_ids[packet.container_id] = packet.state_id
if packet.container_id == 0:
self.g.inv = slots
self.g.inventory_partial = packet.partial
self.g.cursor = self.to_slot(packet.carried_item)
elif self.g.get('window'):
self.g.window.contents = slots
self.g.cursor = self.to_slot(packet.carried_item)
def handle_slot(self, packet):
self.g.inventory_state_ids[packet.container_id] = packet.state_id
item = self.to_slot(packet.item)
if packet.item.components_unknown:
self.g.inventory_partial = True
if packet.container_id == 0:
self.g.inv[packet.slot] = item
elif packet.container_id == -1 and packet.slot == -1:
self.g.cursor = item
elif self.g.get('window'):
self.g.window.contents[packet.slot] = item
def handle_cursor(self, packet):
if packet.item.components_unknown:
self.g.inventory_partial = True
self.g.cursor = self.to_slot(packet.item)
+305
View File
@@ -1,8 +1,18 @@
import json
from math import floor
from munch import Munch
from minecraft.networking.packets import clientbound, serverbound
from minecraft.networking.modern import (
AddEntityPacket, ContainerSetContentPacket, ContainerSetSlotPacket,
PaletteSection,
EntityPositionSyncPacket, MoveEntityPositionPacket,
MoveEntityPositionRotationPacket, MoveEntityRotationPacket,
PlayerInfoRemovePacket, PlayerInfoUpdatePacket, RemoveEntitiesPacket,
SetCursorItemPacket,
)
from mosfet.protocol import packets
from mosfet.protocol.types import Slot
from mosfet.info import blocks, mcdata, mobs
@@ -172,3 +182,298 @@ class ChatManager:
packet = serverbound.play.ChatPacket()
packet.message = text
self.g.connection.write_packet(packet)
class ChunkColumn:
def __init__(self, x, z, sections, min_section):
self.x = x
self.z = z
self.sections = {
min_section + offset: section
for offset, section in enumerate(sections)
}
def get_block_at(self, x, y, z):
section = self.sections.get(y // 16)
if section is None:
return None
index = ((y & 15) * 16 + (z & 15)) * 16 + (x & 15)
return section['blocks'].get(index)
def set_block_at(self, x, y, z, block_state_id):
section = self.sections.get(y // 16)
if section is None:
return None
index = ((y & 15) * 16 + (z & 15)) * 16 + (x & 15)
previous = section['blocks'].get(index)
section['blocks'].set(index, block_state_id)
return previous
class WorldState:
"""Sparse 26.3 chunk/block-state cache used by world and navigation."""
def __init__(self, min_y=-64):
self.min_y = min_y
self.min_section = min_y // 16
self.chunks = {}
self.index = {}
from mosfet.info import blocks
self.indexed_ids = blocks.INDEXED_IDS
self.center = None
self.radius = None
self.dimension = 'minecraft:overworld'
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
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 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:
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)
def get_chunk(self, x, z):
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))
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 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)
}
if not self.index[state_id]:
del self.index[state_id]
def unload_all_chunks(self):
self.chunks.clear()
self.index.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)))
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))
@property
def loaded_chunk_count(self):
return len(self.chunks)
def __repr__(self):
return ('WorldState(dimension={!r}, chunks={}, min_y={}, center={!r}, '
'radius={!r})').format(
self.dimension, len(self.chunks), self.min_y,
self.center, self.radius)
class EntityManager:
def __init__(self, global_state):
self.g = global_state
self.g.mobs = self.g.get('mobs') or {}
self.g.players = self.g.get('players') or {}
self.g.objects = self.g.get('objects') or {}
self.g.player_names = self.g.get('player_names') or {}
register = self.g.connection.register_packet_listener
for packet_type, handler in (
(AddEntityPacket, self.handle_add_entity),
(RemoveEntitiesPacket, self.handle_remove_entities),
(MoveEntityPositionPacket, self.handle_move),
(MoveEntityPositionRotationPacket, self.handle_move),
(MoveEntityRotationPacket, self.handle_rotation),
(EntityPositionSyncPacket, self.handle_position_sync),
(PlayerInfoUpdatePacket, self.handle_player_info),
(PlayerInfoRemovePacket, self.handle_player_info_remove),
):
register(handler, packet_type)
def handle_add_entity(self, packet):
name = mobs.MOB_NAMES.get(packet.entity_type_id, 'unknown')
common = dict(entity_id=packet.entity_id, x=packet.x,
y=packet.y, z=packet.z)
if name == 'player':
player_uuid = str(packet.entity_uuid)
self.g.players[packet.entity_id] = Munch(
**common, player_uuid=player_uuid,
yaw=packet.yaw, pitch=packet.pitch)
elif name == 'item':
self.g.objects[packet.entity_id] = Munch(
**common, entity_uuid=str(packet.entity_uuid),
velocity_x=packet.velocity_x * 8000,
velocity_y=packet.velocity_y * 8000,
velocity_z=packet.velocity_z * 8000)
else:
self.g.mobs[packet.entity_id] = Munch(
**common, entity_uuid=str(packet.entity_uuid),
type=packet.entity_type_id, yaw=packet.yaw,
pitch=packet.pitch)
def _entity(self, entity_id):
for collection in (self.g.players, self.g.mobs, self.g.objects):
entity = collection.get(entity_id)
if entity is not None:
return entity
return None
def handle_move(self, packet):
entity = self._entity(packet.entity_id)
if entity is None:
return
entity.x += packet.delta_x
entity.y += packet.delta_y
entity.z += packet.delta_z
if hasattr(packet, 'yaw'):
entity.yaw = packet.yaw
entity.pitch = packet.pitch
def handle_rotation(self, packet):
entity = self._entity(packet.entity_id)
if entity is not None:
entity.yaw = packet.yaw
entity.pitch = packet.pitch
def handle_position_sync(self, packet):
entity = self._entity(packet.entity_id)
if entity is not None:
entity.x, entity.y, entity.z = packet.x, packet.y, packet.z
entity.yaw = packet.yaw
entity.pitch = packet.pitch
def handle_remove_entities(self, packet):
for entity_id in packet.entity_ids:
self.g.players.pop(entity_id, None)
self.g.mobs.pop(entity_id, None)
self.g.objects.pop(entity_id, None)
def handle_player_info(self, packet):
for profile_uuid, name in packet.entries:
if name:
uuid_text = str(profile_uuid)
self.g.player_names[uuid_text] = name
self.g.player_names[name] = profile_uuid
self.g.player_names[name.lower()] = profile_uuid
def handle_player_info_remove(self, packet):
removed = {str(uuid) for uuid in packet.uuids}
for key, value in list(self.g.player_names.items()):
if str(value) in removed or str(key) in removed:
del self.g.player_names[key]
class InventoryManager:
def __init__(self, global_state):
self.g = global_state
self.g.inv = self.g.get('inv') or {}
self.g.inventory_state_ids = self.g.get('inventory_state_ids') or {}
self.g.inventory_partial = False
self.g.cursor = Slot(present=False)
register = self.g.connection.register_packet_listener
register(self.handle_content, ContainerSetContentPacket)
register(self.handle_slot, ContainerSetSlotPacket)
register(self.handle_cursor, SetCursorItemPacket)
@staticmethod
def to_slot(item):
return Slot(item.present, item.item_id, item.item_count,
components=item.components,
component_ids=item.component_ids,
removed_components=item.removed_components,
components_unknown=item.components_unknown)
def handle_content(self, packet):
slots = {index: self.to_slot(item)
for index, item in enumerate(packet.items)}
self.g.inventory_state_ids[packet.container_id] = packet.state_id
if packet.container_id == 0:
self.g.inv = slots
self.g.inventory_partial = packet.partial
self.g.cursor = self.to_slot(packet.carried_item)
elif self.g.get('window'):
self.g.window.contents = slots
self.g.cursor = self.to_slot(packet.carried_item)
def handle_slot(self, packet):
self.g.inventory_state_ids[packet.container_id] = packet.state_id
item = self.to_slot(packet.item)
if packet.item.components_unknown:
self.g.inventory_partial = True
if packet.container_id == 0:
self.g.inv[packet.slot] = item
elif packet.container_id == -1 and packet.slot == -1:
self.g.cursor = item
elif self.g.get('window'):
self.g.window.contents[packet.slot] = item
def handle_cursor(self, packet):
if packet.item.components_unknown:
self.g.inventory_partial = True
self.g.cursor = self.to_slot(packet.item)
-3
View File
@@ -49,13 +49,10 @@ class World:
yield check
def find_blocks_indexed(self, center, block_ids, distance=0):
print('finding', block_ids)
index = []
for bid in block_ids:
index.extend(self.g.chunks.index.get(bid, []))
print('index', index)
result = []
for block in index:
if self.block_at(*block) not in block_ids:
-220
View File
@@ -1,220 +0,0 @@
"""Small 26.3 world cache used while the legacy automation is being ported.
It retains numeric block-state IDs from chunk palettes and block updates. It
intentionally does not assign 1.16 block meanings to modern state IDs.
"""
from math import floor
class PaletteSection:
def __init__(self, bits, palette, words, size):
self.bits = bits
self.palette = palette
self.words = words
self.size = size
self.overrides = {}
self.mask = (1 << bits) - 1 if bits else 0
self.per_word = 64 // bits if bits else 0
def get(self, index):
if index in self.overrides:
return self.overrides[index]
if self.bits == 0:
return self.palette[0]
word = self.words[index // self.per_word]
palette_index = (word >> ((index % self.per_word) * self.bits)) & self.mask
return self.palette[palette_index] if self.palette is not None else palette_index
def set(self, index, block_state_id):
self.overrides[index] = block_state_id
class ChunkColumn:
def __init__(self, x, z, sections, min_section):
self.x = x
self.z = z
self.sections = {
min_section + offset: section
for offset, section in enumerate(sections)
}
def get_block_at(self, x, y, z):
section = self.sections.get(y // 16)
if section is None:
return None
index = ((y & 15) * 16 + (z & 15)) * 16 + (x & 15)
return section['blocks'].get(index)
def set_block_at(self, x, y, z, block_state_id):
section = self.sections.get(y // 16)
if section is None:
return None
index = ((y & 15) * 16 + (z & 15)) * 16 + (x & 15)
previous = section['blocks'].get(index)
section['blocks'].set(index, block_state_id)
return previous
class WorldState:
"""Sparse loaded-chunk/block-state cache with the old manager's read API."""
def __init__(self, min_y=-64):
self.min_y = min_y
self.min_section = min_y // 16
self.chunks = {}
self.index = {}
from mosfet.info import blocks
self.indexed_ids = blocks.INDEXED_IDS
self.center = None
self.radius = None
self.dimension = 'minecraft:overworld'
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
def load_chunk(self, packet):
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 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:
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)
def get_chunk(self, x, z):
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))
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 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)
}
if not self.index[state_id]:
del self.index[state_id]
def unload_all_chunks(self):
self.chunks.clear()
self.index.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)))
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))
@property
def loaded_chunk_count(self):
return len(self.chunks)
def __repr__(self):
return ('WorldState(dimension={!r}, chunks={}, min_y={}, center={!r}, '
'radius={!r})').format(
self.dimension, len(self.chunks), self.min_y,
self.center, self.radius)
def read_palette_container(stream, *, size, local_bits_max):
from minecraft.networking.types import Byte, UnsignedLong, VarInt
bits = Byte.read(stream)
if bits == 0:
palette = [VarInt.read(stream)]
elif bits <= local_bits_max:
palette_size = VarInt.read(stream)
palette = [VarInt.read(stream) for _ in range(palette_size)]
else:
palette = None
word_count = ((size + (64 // bits) - 1) // (64 // bits)) if bits else 0
words = [UnsignedLong.read(stream) for _ in range(word_count)]
return PaletteSection(bits, palette, words, size)
def decode_chunk_sections(data):
from io import BytesIO
from minecraft.networking.types import Short
stream = BytesIO(data)
sections = []
while stream.tell() < len(data):
start = stream.tell()
if len(data) - start < 4:
raise ValueError('truncated chunk section header at byte {}'.format(start))
non_empty_blocks = Short.read(stream)
fluid_count = Short.read(stream)
blocks = read_palette_container(stream, size=4096, local_bits_max=8)
biomes = read_palette_container(stream, size=64, local_bits_max=3)
sections.append({
'non_empty_blocks': non_empty_blocks,
'fluid_count': fluid_count,
'blocks': blocks,
'biomes': biomes,
})
if stream.tell() <= start:
raise ValueError('chunk section decoder made no progress')
return sections