""" Minetest client. Implements the low level protocol and a few commands. Created by reading the docs at http://dev.minetest.net/Network_Protocol and https://github.com/minetest/minetest/blob/master/src/clientserver.h """ import socket from struct import pack, unpack, calcsize from binascii import hexlify from threading import Thread, Semaphore from queue import Queue from collections import defaultdict import math # Packet types. CONTROL = 0x00 ORIGINAL = 0x01 SPLIT = 0x02 RELIABLE = 0x03 # Types of CONTROL packets. CONTROLTYPE_ACK = 0x00 CONTROLTYPE_SET_PEER_ID = 0x01 CONTROLTYPE_PING = 0x02 # Initial sequence number for RELIABLE-type packets. SEQNUM_INITIAL = 0xFFDC # Protocol id. PROTOCOL_ID = 0x4F457403 # No idea. SER_FMT_VER_HIGHEST_READ = 0x1A # Supported protocol versions lifted from official client. MIN_SUPPORTED_PROTOCOL = 0x0d MAX_SUPPORTED_PROTOCOL = 0x16 # Client -> Server command ids. TOSERVER_INIT = 0x10 TOSERVER_INIT2 = 0x11 TOSERVER_PLAYERPOS = 0x23 TOSERVER_CHAT_MESSAGE = 0x32 TOSERVER_RESPAWN = 0x38 TOSERVER_DAMAGE = 0x35 # Server -> Client command ids. TOCLIENT_INIT = 0x10 TOCLIENT_ADDNODE = 0x21 TOCLIENT_REMOVENODE = 0x22 TOCLIENT_INVENTORY = 0x27 TOCLIENT_TIME_OF_DAY = 0x29 TOCLIENT_CHAT_MESSAGE = 0x30 TOCLIENT_HP = 0x33 TOCLIENT_MOVE_PLAYER = 0x34 TOCLIENT_ACCESS_DENIED = 0x35 TOCLIENT_DEATHSCREEN = 0x37 TOCLIENT_NODEDEF = 0x3a TOCLIENT_ANNOUNCE_MEDIA = 0x3c TOCLIENT_ITEMDEF = 0x3d TOCLIENT_PLAY_SOUND = 0x3F TOCLIENT_STOP_SOUND = 0x40 TOCLIENT_PRIVILEGES = 0x41 TOCLIENT_INVENTORY_FORMSPEC = 0x42 TOCLIENT_DETACHED_INVENTORY = 0x43 TOCLIENT_MOVEMENT = 0x45 TOCLIENT_ADD_PARTICLESPAWNER = 0x47 TOCLIENT_BREATH = 0x4e class MinetestClientProtocol(object): """ Class for exchanging messages with a Minetest server. Automatically processes received messages in a separate thread and performs the initial handshake when created. Blocks until the handshake is finished. TODO: resend unacknowledged messages and process out-of-order packets. """ def __init__(self, host, username, password=''): if ':' in host: host, port = host.split(':') server = (host, int(port)) else: server = (host, 30000) self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.server = server self.seqnum = SEQNUM_INITIAL self.peer_id = 0 self.username = username self.password = password # Priority channel, not actually implemented in the official server but # required for the protocol. self.channel = 0 # Buffer with the messages received, filled by the listener thread. self.receive_buffer = Queue() # Last sequence number acknowledged by the server. self.acked = 0 # Buffer for SPLIT-type messages, indexed by sequence number. self.split_buffers = defaultdict(dict) # Send TOSERVER_INIT and start a reliable connection. The order is # strange, but imitates the official client. self._handshake_start() self._start_reliable_connection() # Lock until the handshake is completed. self.handshake_lock = Semaphore(0) # Run listen-and-process asynchronously. thread = Thread(target=self._receive_and_process) thread.daemon = True thread.start() self.handshake_lock.acquire() def _send(self, packet): """ Sends a raw packet, containing only the protocol header. """ header = pack('>IHB', PROTOCOL_ID, self.peer_id, self.channel) self.sock.sendto(header + packet, self.server) def _handshake_start(self): """ Sends the first part of the handshake. """ packet = pack('>HB20s28sHH', TOSERVER_INIT, SER_FMT_VER_HIGHEST_READ, self.username.encode('utf-8'), self.password.encode('utf-8'), MIN_SUPPORTED_PROTOCOL, MAX_SUPPORTED_PROTOCOL) self.send_command(packet) def _handshake_end(self): """ Sends the second and last part of the handshake. """ self.send_command(pack('>H', TOSERVER_INIT2)) def _start_reliable_connection(self): """ Starts a reliable connection by sending an empty reliable packet. """ self.send_command(b'') def disconnect(self): """ Closes the connection. """ # The "disconnect" message is just a RELIABLE without sequence number. self._send(pack('>H', RELIABLE)) def _send_reliable(self, message): """ Sends a reliable message. This message can be a packet of another type, such as CONTROL or ORIGINAL. """ packet = pack('>BH', RELIABLE, self.seqnum & 0xFFFF) + message self.seqnum += 1 self._send(packet) def send_command(self, message): """ Sends a useful message, such as a place or say command. """ start = pack('B', ORIGINAL) self._send_reliable(start + message) def _ack(self, seqnum): """ Sends an ack for the given sequence number. """ self._send(pack('>BBH', CONTROL, CONTROLTYPE_ACK, seqnum)) def receive_command(self): """ Returns a command message from the server, blocking until one arrives. """ return self.receive_buffer.get() def _process_packet(self, packet): """ Processes a packet received. It can be of type - CONTROL, used by the protocol to control the connection (ack, set_peer_id and ping); - RELIABLE in which it requires an ack and contains a further message to be processed; - ORIGINAL, which designates it's a command and it's put in the receive buffer; - or SPLIT, used to send large data. """ packet_type, data = packet[0], packet[1:] if packet_type == CONTROL: if len(data) == 1: assert data[0] == CONTROLTYPE_PING # Do nothing. PING is sent through a reliable packet, so the # response was already sent when we unwrapped it. return control_type, value = unpack('>BH', data) if control_type == CONTROLTYPE_ACK: self.acked = value elif control_type == CONTROLTYPE_SET_PEER_ID: self.peer_id = value self._handshake_end() self.handshake_lock.release() elif packet_type == RELIABLE: seqnum, = unpack('>H', data[:2]) self._ack(seqnum) self._process_packet(data[2:]) elif packet_type == ORIGINAL: self.receive_buffer.put(data) elif packet_type == SPLIT: header_size = calcsize('>HHH') split_header, split_data = data[:header_size], data[header_size:] seqnumber, chunk_count, chunk_num = unpack('>HHH', split_header) self.split_buffers[seqnumber][chunk_num] = split_data if chunk_count - 1 in self.split_buffers[seqnumber]: complete = [] try: for i in range(chunk_count): complete.append(self.split_buffers[seqnumber][i]) except KeyError: # We are missing data, ignore and wait for resend. pass self.receive_buffer.put(b''.join(complete)) del self.split_buffers[seqnumber] else: raise ValueError('Unknown packet type {}'.format(packet_type)) def _receive_and_process(self): """ Constantly listens for incoming packets and processes them as required. """ while True: packet, origin = self.sock.recvfrom(1024) header_size = calcsize('>IHB') header, data = packet[:header_size], packet[header_size:] protocol, peer_id, channel = unpack('>IHB', header) assert protocol == PROTOCOL_ID, 'Unexpected protocol.' assert peer_id == 0x01, 'Unexpected peer id, should be 1 got {}'.format(peer_id) self._process_packet(data) class MinetestClient(object): """ Class for sending commands to a remote Minetest server. This creates a character in the running world, controlled by the methods exposed in this class. """ def __init__(self, server='localhost:30000', username='user', password='', on_message=id): """ Creates a new Minetest Client to send remote commands. 'server' must be in the format 'host:port' or just 'host'. 'username' is the name of the character on the world. 'password' is an optional value used when the server is private. 'on_message' is a function called whenever a chat message arrives. """ self.protocol = MinetestClientProtocol(server, username, password) # We need to constantly listen for server messages to update our # position, HP, etc. To avoid blocking the caller we create a new # thread to process those messages, and wait until we have a confirmed # connection. self.access_denied = None self.init_lock = Semaphore(0) thread = Thread(target=self._receive_and_process) thread.daemon = True thread.start() # Wait until we know our position, otherwise the 'move' method will not # work. self.init_lock.acquire() if self.access_denied is not None: raise ValueError('Access denied. Reason: ' + self.access_denied) self.on_message = on_message # HP is not a critical piece of information for us, so we assume it's full # until the server says otherwise. self.hp = 20 def say(self, message): """ Sends a global chat message. """ message = str(message) encoded = message.encode('UTF-16BE') packet = pack('>HH', TOSERVER_CHAT_MESSAGE, len(message)) + encoded self.protocol.send_command(packet) def respawn(self): """ Resurrects and teleports the dead character. """ packet = pack('>H', TOSERVER_RESPAWN) self.protocol.send_command(packet) def damage(self, amount=20): """ Makes the character damage itself. Amount is measured in half-hearts and defaults to a complete suicide. """ packet = pack('>HB', TOSERVER_DAMAGE, int(amount)) self.protocol.send_command(packet) def move(self, delta_position=(0,0,0), delta_angle=(0,0), key=0x01): """ Moves to a position relative to the player. """ x = self.position[0] + delta_position[0] y = self.position[1] + delta_position[1] z = self.position[2] + delta_position[2] pitch = self.angle[0] + delta_angle[0] yaw = self.angle[1] + delta_angle[1] self.teleport(position=(x, y, z), angle=(pitch, yaw), key=key) def teleport(self, position=None, speed=(0,0,0), angle=None, key=0x01): """ Moves to an absolute position. """ position = position or self.position angle = angle or self.angle x, y, z = map(lambda k: int(k*1000), position) dx, dy, dz = map(lambda k: int(k*100), speed) pitch, yaw = map(lambda k: int(k*100), angle) packet = pack('>H3i3i2iI', TOSERVER_PLAYERPOS, x, y, z, dx, dy, dz, pitch, yaw, key) self.protocol.send_command(packet) self.position = position self.angle = angle def turn(self, degrees=90): """ Makes the character face a different direction. Amount of degrees can be negative. """ new_angle = (self.angle[0], self.angle[1] + degrees) self.teleport(angle=new_angle) def walk(self, distance=1): """ Moves a number of blocks forward, relative to the direction the character is looking. """ dx = distance * math.cos((90 + self.angle[1]) / 180 * math.pi) dz = distance * math.sin((90 + self.angle[1]) / 180 * math.pi) self.move((dx, 0, dz)) def disconnect(self): """ Disconnects the client, removing the character from the world. """ self.protocol.disconnect() def _receive_and_process(self): """ Receive commands from the server and process them synchronously. Most commands are not implemented because we didn't have a need. """ while True: packet = self.protocol.receive_command() (command_type,), data = unpack('>H', packet[:2]), packet[2:] if command_type == TOCLIENT_INIT: # No useful info here. pass elif command_type == TOCLIENT_MOVE_PLAYER: x10000, y10000, z10000, pitch1000, yaw1000 = unpack('>3i2i', data) self.position = (x10000/10000, y10000/10000, z10000/10000) self.angle = (pitch1000/1000, yaw1000/1000) self.init_lock.release() elif command_type == TOCLIENT_CHAT_MESSAGE: length, bin_message = unpack('>H', data[:2]), data[2:] # Length is not matching for some reason. #assert len(bin_message) / 2 == length message = bin_message.decode('UTF-16BE') self.on_message(message) elif command_type == TOCLIENT_DEATHSCREEN: self.respawn() elif command_type == TOCLIENT_HP: self.hp, = unpack('B', data) elif command_type == TOCLIENT_INVENTORY_FORMSPEC: pass elif command_type == TOCLIENT_INVENTORY: pass elif command_type == TOCLIENT_PRIVILEGES: pass elif command_type == TOCLIENT_MOVEMENT: pass elif command_type == TOCLIENT_BREATH: pass elif command_type == TOCLIENT_DETACHED_INVENTORY: pass elif command_type == TOCLIENT_TIME_OF_DAY: pass elif command_type == TOCLIENT_REMOVENODE: pass elif command_type == TOCLIENT_ADDNODE: pass elif command_type == TOCLIENT_PLAY_SOUND: pass elif command_type == TOCLIENT_STOP_SOUND: pass elif command_type == TOCLIENT_NODEDEF: pass elif command_type == TOCLIENT_ANNOUNCE_MEDIA: pass elif command_type == TOCLIENT_ITEMDEF: pass elif command_type == TOCLIENT_ACCESS_DENIED: length, bin_message = unpack('>H', data[:2]), data[2:] self.access_denied = bin_message.decode('UTF-16BE') self.init_lock.release() else: print('Unknown command type {}.'.format(hex(command_type))) if __name__ == '__main__': import sys import time args = sys.argv[1:] assert len(args) <= 3, 'Too many arguments, expected no more than 3' # Load hostname, username and password from the command line arguments. # Defaults to localhost:30000, 'user' and empty password (for public # servers). client = MinetestClient(*args) try: # Print chat messages received from other players. client.on_message = print # Send as chat message any line typed in the standard input. while not sys.stdin.closed: line = sys.stdin.readline().rstrip() client.say(line) finally: client.protocol.disconnect()