minetesting/client.py

419 lines
15 KiB
Python

"""
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()