b8e5df778e
Моды грузятся через -javaagent, game.jar не патчится, переживают ре-обфускацию. Что внутри: - агент rsx-bridge.jar: профиль сборки игры, патчи A2/A6, загрузка модов через родной загрузчик игры (ilxq -> special.Main) - патч лаунчера: окно выбора «С модами / Ванилла» по кнопке «Играть» и защита от автоапдейта лаунчера - install/uninstall: всё ставится в %LOCALAPPDATA%\rsx-mod, пути не хардкодятся - build: три готовых артефакта в dist, нужен только JDK - testserver: локальный сервер, принимает любой ник и токен Проверено на живом клиенте: мод грузится и выполняется в игровом потоке, в логе видно игрока и мир.
416 lines
12 KiB
Python
416 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Offline stand-in for the RustexRemake server: accepts any nickname and
|
|
token and drops the client into an empty world, so the modloader can be
|
|
verified end to end without the live server.
|
|
|
|
Protocol: Minecraft 1.12.2 (340), no compression, no encryption.
|
|
Run: python server.py [--port 25566] [--host 0.0.0.0]
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import select
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid as uuidlib
|
|
|
|
PROTOCOL = 340
|
|
GAMEMODE_SURVIVAL = 0
|
|
DIMENSION_OVERWORLD = 0
|
|
DIFFICULTY_NORMAL = 1
|
|
WORLD_NAME = "flat"
|
|
|
|
CLIENTBOUND_PLAY = {
|
|
0x00: "KEEP_ALIVE",
|
|
0x01: "JOIN_GAME",
|
|
0x06: "SPAWN_POSITION",
|
|
0x08: "PLAYER_POS_LOOK",
|
|
0x23: "PLUGIN_MESSAGE",
|
|
}
|
|
SERVERBOUND_PLAY = {
|
|
0x00: "KEEP_ALIVE",
|
|
0x01: "CHAT",
|
|
0x02: "CLIENT_STATUS",
|
|
0x03: "CLIENT_SETTINGS",
|
|
0x04: "PLUGIN_MESSAGE",
|
|
0x06: "POSITION",
|
|
0x07: "POSITION_LOOK",
|
|
0x1A: "TELEPORT_CONFIRM",
|
|
}
|
|
|
|
|
|
class ProtocolError(Exception):
|
|
pass
|
|
|
|
|
|
class Conn:
|
|
def __init__(self, sock, peer):
|
|
self.sock = sock
|
|
self.peer = peer
|
|
self.buf = b""
|
|
|
|
def close(self):
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def _fill(self, n):
|
|
while len(self.buf) < n:
|
|
chunk = self.sock.recv(65536)
|
|
if not chunk:
|
|
raise ProtocolError("closed by peer")
|
|
self.buf += chunk
|
|
|
|
def read_exact(self, n):
|
|
self._fill(n)
|
|
out, self.buf = self.buf[:n], self.buf[n:]
|
|
return out
|
|
|
|
def read_varint(self):
|
|
value = 0
|
|
for shift in range(0, 35, 7):
|
|
byte = self.read_exact(1)[0]
|
|
value |= (byte & 0x7F) << shift
|
|
if not byte & 0x80:
|
|
return value
|
|
raise ProtocolError("varint too long")
|
|
|
|
def read_string(self):
|
|
return self.read_exact(self.read_varint()).decode("utf-8", "replace")
|
|
|
|
def read_ushort(self):
|
|
return struct.unpack(">H", self.read_exact(2))[0]
|
|
|
|
def read_packet(self):
|
|
length = self.read_varint()
|
|
if length <= 0 or length > 2_000_000:
|
|
raise ProtocolError("bad packet length %d" % length)
|
|
pid = self.read_varint()
|
|
payload = self.read_exact(length - varint_size(pid))
|
|
return pid, payload
|
|
|
|
def send(self, pid, payload=b""):
|
|
body = varint(pid) + payload
|
|
self.sock.sendall(varint(len(body)) + body)
|
|
|
|
|
|
def varint(value):
|
|
out = b""
|
|
while True:
|
|
byte = value & 0x7F
|
|
value >>= 7
|
|
out += bytes([byte | (0x80 if value else 0)])
|
|
if not value:
|
|
return out
|
|
|
|
|
|
def varint_size(value):
|
|
size = 1
|
|
while value >= 0x80:
|
|
value >>= 7
|
|
size += 1
|
|
return size
|
|
|
|
|
|
def mc_string(text):
|
|
raw = text.encode("utf-8")
|
|
return varint(len(raw)) + raw
|
|
|
|
|
|
def mc_bool(value):
|
|
return b"\x01" if value else b"\x00"
|
|
|
|
|
|
def offline_uuid(name):
|
|
return uuidlib.uuid3(uuidlib.NAMESPACE_DNS, "OfflinePlayer." + name)
|
|
|
|
|
|
def log(name, fmt, *args):
|
|
stamp = time.strftime("%H:%M:%S")
|
|
sys.stdout.write("[%s] %-12s %s\n" % (stamp, name, fmt % args if args else fmt))
|
|
sys.stdout.flush()
|
|
|
|
|
|
def send_status(conn):
|
|
payload = (
|
|
mc_string(json_status())
|
|
)
|
|
conn.send(0x00, payload)
|
|
|
|
|
|
def json_status():
|
|
return (
|
|
'{"version":{"name":"RustexRemake","protocol":%d},'
|
|
'"players":{"max":20,"online":1},'
|
|
'"description":{"text":"rsx modloader test server"}}' % PROTOCOL
|
|
)
|
|
|
|
|
|
def send_keep_alive(conn, ident):
|
|
conn.send(0x00, varint(ident & 0x7FFFFFFF))
|
|
|
|
|
|
def send_join_game(conn, entity_id):
|
|
payload = b"".join([
|
|
struct.pack(">i", entity_id),
|
|
bytes([GAMEMODE_SURVIVAL]),
|
|
struct.pack(">i", DIMENSION_OVERWORLD),
|
|
bytes([DIFFICULTY_NORMAL]),
|
|
bytes([20]),
|
|
mc_string(WORLD_NAME),
|
|
mc_bool(False),
|
|
])
|
|
conn.send(0x01, payload)
|
|
|
|
|
|
def send_spawn_position(conn, x=0.5, y=64.0, z=0.5):
|
|
payload = struct.pack(">ddd", x, y, z)
|
|
conn.send(0x06, payload)
|
|
|
|
|
|
def send_pos_look(conn, x, y, z, yaw, pitch, teleport_id):
|
|
payload = b"".join([
|
|
struct.pack(">ddd", x, y, z),
|
|
struct.pack(">ff", yaw, pitch),
|
|
bytes([0]),
|
|
varint(teleport_id),
|
|
])
|
|
conn.send(0x08, payload)
|
|
|
|
|
|
def send_brand(conn, brand):
|
|
inner = mc_string(brand)
|
|
conn.send(0x23, mc_string("MC|Brand") + varint(len(inner)) + inner)
|
|
|
|
|
|
def handle(conn):
|
|
name = None
|
|
entity_id = None
|
|
teleport_id = 0
|
|
keepalive_id = 0x1234
|
|
last_keepalive = 0.0
|
|
try:
|
|
pid, payload = conn.read_packet()
|
|
if pid != 0x00:
|
|
raise ProtocolError("expected handshake, got %d" % pid)
|
|
cursor = Cursor(payload)
|
|
protocol = cursor.varint()
|
|
host = cursor.string()
|
|
port = cursor.ushort()
|
|
next_state = cursor.varint()
|
|
log("handshake", "protocol=%d host=%s port=%d next_state=%d",
|
|
protocol, host, port, next_state)
|
|
|
|
if next_state == 1:
|
|
pid, _ = conn.read_packet()
|
|
send_status(conn)
|
|
log("status", "ping answered")
|
|
return
|
|
if next_state != 2:
|
|
raise ProtocolError("unsupported next_state %d" % next_state)
|
|
|
|
pid, payload = conn.read_packet()
|
|
if pid != 0x00:
|
|
raise ProtocolError("expected login start, got %d" % pid)
|
|
cursor = Cursor(payload)
|
|
name = cursor.string()
|
|
profile_id = None
|
|
if cursor.remaining() > 0:
|
|
try:
|
|
profile_id = cursor.string()
|
|
except ProtocolError:
|
|
pass
|
|
if name is None:
|
|
raise ProtocolError("no nickname")
|
|
profile_id = profile_id or str(offline_uuid(name)).replace("-", "")
|
|
log("login", "nick=%r token-ignored uuid=%s", name, profile_id)
|
|
|
|
conn.send(0x02, mc_string(profile_id) + mc_string(name))
|
|
log("login", "login success sent, any token accepted")
|
|
|
|
deadline = time.time() + 20
|
|
while time.time() < deadline:
|
|
pid, payload = conn.read_packet()
|
|
if pid == 0x03:
|
|
log("login", "client acknowledged")
|
|
break
|
|
if pid == 0x00:
|
|
continue
|
|
log("login", "unexpected packet 0x%02X before play" % pid)
|
|
else:
|
|
raise ProtocolError("no login acknowledgement")
|
|
|
|
entity_id = int.from_bytes(hashlib.sha256(name.encode()).digest()[:4],
|
|
"big") & 0x7FFFFFFF
|
|
send_brand(conn, "rsx-testserver")
|
|
send_join_game(conn, entity_id)
|
|
send_pos_look(conn, 0.5, 64.0, 0.5, 0.0, 0.0, teleport_id)
|
|
log("play", "join game sent, entity=%d, spawn sent" % entity_id)
|
|
|
|
while True:
|
|
if time.time() - last_keepalive > 15:
|
|
last_keepalive = time.time()
|
|
keepalive_id = (keepalive_id + 1) & 0x7FFFFFFF
|
|
send_keep_alive(conn, keepalive_id)
|
|
|
|
pid, payload = conn.read_packet()
|
|
label = SERVERBOUND_PLAY.get(pid, "0x%02X" % pid)
|
|
if pid == 0x00:
|
|
send_keep_alive(conn, keepalive_id)
|
|
log("play", "keep alive")
|
|
elif pid == 0x1A:
|
|
log("play", "teleport confirmed")
|
|
teleport_id += 1
|
|
send_pos_look(conn, 0.5, 64.0, 0.5, 0.0, 0.0, teleport_id)
|
|
log("play", "position sent after teleport confirm")
|
|
elif pid == 0x02:
|
|
log("play", "client status (respawn?): %s", payload.hex())
|
|
elif pid == 0x07:
|
|
log("play", "player position+look received")
|
|
else:
|
|
log("play", "%s (%d bytes)", label, len(payload))
|
|
|
|
except ProtocolError as exc:
|
|
log("close", "%s: %s", conn.peer, exc)
|
|
except OSError as exc:
|
|
log("close", "%s: %s", conn.peer, exc)
|
|
except Exception as exc: # noqa: BLE001 - stand-in server, log and move on
|
|
log("error", "%s: %r", conn.peer, exc)
|
|
finally:
|
|
if name:
|
|
log("bye", "nick=%r entity=%s", name, entity_id)
|
|
conn.close()
|
|
|
|
|
|
class Cursor:
|
|
def __init__(self, data):
|
|
self.data = data
|
|
self.pos = 0
|
|
|
|
def remaining(self):
|
|
return len(self.data) - self.pos
|
|
|
|
def take(self, n):
|
|
if self.pos + n > len(self.data):
|
|
raise ProtocolError("packet truncated")
|
|
out = self.data[self.pos:self.pos + n]
|
|
self.pos += n
|
|
return out
|
|
|
|
def byte(self):
|
|
return self.take(1)[0]
|
|
|
|
def ubyte(self):
|
|
return self.byte()
|
|
|
|
def ushort(self):
|
|
return struct.unpack(">H", self.take(2))[0]
|
|
|
|
def varint(self):
|
|
value = 0
|
|
for shift in range(0, 35, 7):
|
|
byte = self.byte()
|
|
value |= (byte & 0x7F) << shift
|
|
if not byte & 0x80:
|
|
return value
|
|
raise ProtocolError("varint too long")
|
|
|
|
def string(self):
|
|
return self.take(self.varint()).decode("utf-8", "replace")
|
|
|
|
|
|
def parse_ports(spec):
|
|
ports = []
|
|
for chunk in spec.split(","):
|
|
chunk = chunk.strip()
|
|
if "-" in chunk:
|
|
first, last = chunk.split("-", 1)
|
|
ports.extend(range(int(first), int(last) + 1))
|
|
elif chunk:
|
|
ports.append(int(chunk))
|
|
return ports
|
|
|
|
|
|
def serve_ping(ports, host):
|
|
"""The client asks http://<host>/server-ping for its server list and reads
|
|
lines of port~mode~x~y. 'Offline' skips the per-server ping, which is what
|
|
we want: the stand-in has nothing to measure."""
|
|
body = "".join("%d~Offline~0~0\n" % port for port in ports)
|
|
try:
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
except ImportError:
|
|
log("ping", "http.server unavailable, ping endpoint disabled")
|
|
return None
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self): # noqa: N802 - required by BaseHTTPRequestHandler
|
|
payload = body.encode("utf-8")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def log_message(self, fmt, *args):
|
|
log("ping", self.path)
|
|
|
|
try:
|
|
httpd = HTTPServer((host, 80), Handler)
|
|
except OSError as exc:
|
|
log("ping", "cannot listen on port 80 (%s); list will stay empty" % exc)
|
|
return None
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
log("ping", "serving /server-ping on %s:80 with %d server(s)"
|
|
% (host, len(ports)))
|
|
return httpd
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--host", default="0.0.0.0")
|
|
parser.add_argument("--ports", default="25566",
|
|
help="ports or ranges, e.g. 25566-25573")
|
|
parser.add_argument("--no-http", action="store_true",
|
|
help="do not answer the client server-ping request")
|
|
args = parser.parse_args()
|
|
|
|
ports = parse_ports(args.ports)
|
|
listeners = []
|
|
for port in ports:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
sock.bind((args.host, port))
|
|
except OSError as exc:
|
|
log("server", "cannot bind %s:%d (%s)" % (args.host, port, exc))
|
|
sock.close()
|
|
continue
|
|
sock.listen(8)
|
|
listeners.append(sock)
|
|
log("server", "listening on %s:%d, any nick/token accepted",
|
|
args.host, port)
|
|
|
|
if not args.no_http:
|
|
serve_ping(ports, args.host)
|
|
|
|
try:
|
|
while True:
|
|
ready, _, _ = select.select(listeners, [], [], 1.0)
|
|
for listener in ready:
|
|
sock, peer = listener.accept()
|
|
conn = Conn(sock, "%s:%s" % peer)
|
|
threading.Thread(target=handle, args=(conn,),
|
|
daemon=True).start()
|
|
except KeyboardInterrupt:
|
|
log("server", "interrupted")
|
|
finally:
|
|
for listener in listeners:
|
|
listener.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |