This repository has been archived by the owner on Dec 29, 2021. It is now read-only.
forked from boppreh/minetesting
-
Notifications
You must be signed in to change notification settings - Fork 1
/
client.py
418 lines (366 loc) · 15.2 KB
/
client.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
"""
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()