Модлоадер RustexRemake: агент, патч лаунчера, установщик и сборка
Моды грузятся через -javaagent, game.jar не патчится, переживают ре-обфускацию. Что внутри: - агент rsx-bridge.jar: профиль сборки игры, патчи A2/A6, загрузка модов через родной загрузчик игры (ilxq -> special.Main) - патч лаунчера: окно выбора «С модами / Ванилла» по кнопке «Играть» и защита от автоапдейта лаунчера - install/uninstall: всё ставится в %LOCALAPPDATA%\rsx-mod, пути не хардкодятся - build: три готовых артефакта в dist, нужен только JDK - testserver: локальный сервер, принимает любой ник и токен Проверено на живом клиенте: мод грузится и выполняется в игровом потоке, в логе видно игрока и мир.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-test for server.py: runs the stand-in server on a spare port and talks
|
||||
to it with a hand-rolled 1.12.2 client, checking handshake, login and the
|
||||
play-state packets the game needs to reach a world."""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, r"G:\rsx-mod\testserver")
|
||||
|
||||
import server as srv # noqa: E402
|
||||
|
||||
|
||||
def read_varint(sock):
|
||||
value = 0
|
||||
for shift in range(0, 35, 7):
|
||||
chunk = sock.recv(1)
|
||||
if not chunk:
|
||||
raise AssertionError("closed")
|
||||
byte = chunk[0]
|
||||
value |= (byte & 0x7F) << shift
|
||||
if not byte & 0x80:
|
||||
return value
|
||||
raise AssertionError("varint too long")
|
||||
|
||||
|
||||
def read_packet(sock):
|
||||
length = read_varint(sock)
|
||||
data = b""
|
||||
while len(data) < length:
|
||||
chunk = sock.recv(length - len(data))
|
||||
if not chunk:
|
||||
raise AssertionError("closed mid packet")
|
||||
data += chunk
|
||||
pid_val = 0
|
||||
shift = 0
|
||||
pos = 0
|
||||
while True:
|
||||
byte = data[pos]
|
||||
pos += 1
|
||||
pid_val |= (byte & 0x7F) << shift
|
||||
if not byte & 0x80:
|
||||
break
|
||||
shift += 7
|
||||
return pid_val, data[pos:]
|
||||
|
||||
|
||||
def send_packet(sock, pid, payload=b""):
|
||||
body = srv.varint(pid) + payload
|
||||
sock.sendall(srv.varint(len(body)) + body)
|
||||
|
||||
|
||||
def drain(sock, wanted, budget=8.0):
|
||||
seen = {}
|
||||
deadline = time.time() + budget
|
||||
while time.time() < deadline and not wanted.issubset(seen):
|
||||
try:
|
||||
pid, payload = read_packet(sock)
|
||||
except (socket.timeout, TimeoutError, AssertionError, OSError):
|
||||
break
|
||||
seen.setdefault(pid, payload)
|
||||
return seen
|
||||
|
||||
|
||||
def main():
|
||||
port = 25599
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind(("127.0.0.1", port))
|
||||
listener.listen(4)
|
||||
|
||||
def accept_loop():
|
||||
try:
|
||||
sock, peer = listener.accept()
|
||||
srv.handle(srv.Conn(sock, "%s:%s" % peer))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=accept_loop, daemon=True).start()
|
||||
|
||||
sock = socket.create_connection(("127.0.0.1", port), timeout=10)
|
||||
sock.settimeout(10)
|
||||
results = []
|
||||
|
||||
handshake = (srv.varint(srv.PROTOCOL) + srv.mc_string("127.0.0.1")
|
||||
+ struct.pack(">H", port) + srv.varint(2))
|
||||
send_packet(sock, 0x00, handshake)
|
||||
results.append(("handshake sent", True))
|
||||
|
||||
send_packet(sock, 0x00,
|
||||
srv.mc_string("TestPilot")
|
||||
+ srv.mc_string("00000000-0000-0000-0000-000000000000"))
|
||||
pid, payload = read_packet(sock)
|
||||
cursor = srv.Cursor(payload)
|
||||
uuid_txt = cursor.string()
|
||||
nick = cursor.string()
|
||||
results.append(("login success", pid == 0x02 and nick == "TestPilot"))
|
||||
uuid_ok = len(uuid_txt) in (32, 36)
|
||||
try:
|
||||
uuid.UUID(uuid_txt)
|
||||
except (ValueError, AttributeError):
|
||||
uuid_ok = False
|
||||
results.append(("uuid parseable (%d chars)" % len(uuid_txt), uuid_ok))
|
||||
|
||||
send_packet(sock, 0x03, b"")
|
||||
seen = drain(sock, {0x01, 0x08, 0x23})
|
||||
results.append(("join game received", 0x01 in seen))
|
||||
results.append(("spawn position received", 0x08 in seen))
|
||||
results.append(("brand received", 0x23 in seen))
|
||||
|
||||
if 0x01 in seen:
|
||||
cur = srv.Cursor(seen[0x01])
|
||||
entity = struct.unpack(">i", cur.take(4))[0]
|
||||
gamemode = cur.ubyte()
|
||||
dimension = struct.unpack(">i", cur.take(4))[0]
|
||||
cur.ubyte()
|
||||
cur.ubyte()
|
||||
level = cur.string()
|
||||
results.append(("join game parses (entity=%d gm=%d dim=%d %s)"
|
||||
% (entity, gamemode, dimension, level),
|
||||
level == srv.WORLD_NAME))
|
||||
|
||||
if 0x08 in seen:
|
||||
cur = srv.Cursor(seen[0x08])
|
||||
x = struct.unpack(">d", cur.take(8))[0]
|
||||
y = struct.unpack(">d", cur.take(8))[0]
|
||||
z = struct.unpack(">d", cur.take(8))[0]
|
||||
cur.take(8)
|
||||
cur.ubyte()
|
||||
tid = cur.varint()
|
||||
results.append(("spawn parses (%.1f/%.1f/%.1f tid=%d)"
|
||||
% (x, y, z, tid), y == 64.0 and tid == 0))
|
||||
|
||||
send_packet(sock, 0x00, srv.varint(0x1234))
|
||||
send_packet(sock, 0x03, srv.mc_string("ru_ru") + bytes([8])
|
||||
+ srv.varint(0) + b"\x01" + bytes([2]) + bytes([127])
|
||||
+ srv.varint(1))
|
||||
send_packet(sock, 0x07,
|
||||
struct.pack(">dddff?", 0.5, 64.0, 0.5, 0.0, 0.0, True))
|
||||
alive = drain(sock, {0x00}, budget=18.0)
|
||||
results.append(("keep alive flow", 0x00 in alive))
|
||||
|
||||
sock.close()
|
||||
listener.close()
|
||||
|
||||
print("== self-test ==")
|
||||
failed = 0
|
||||
for label, ok in results:
|
||||
print(" %-40s %s" % (label, "OK" if ok else "FAIL"))
|
||||
failed += 0 if ok else 1
|
||||
print(" %d check(s) failed" % failed)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,416 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user