master-server replay + trampler RE: protocol, hashes, footprints, map renderer
- master_scrape.py: live master-server (ger.hologryph.com) ClientMessage replay over the two-socket /login + /connect handshake (PlayFab ticket auth). Pulled compartment defs, shop prices, research tree, storage, characters, expedition -> extracted/master_*.json - PlayFab confirmed auth-only for this title (Economy disabled); docs corrected - trampler_hashes.py: blueprint hash algo MD5(UTF8(compact-JSON)); CompartmentsHash(#1) and ConnectionsHash(#3) verified & generatable from scratch - walkerdto_to_blueprint.py: WalkerDto(expedition) -> WalkerBlueprintDto, enum int<->name, verified by storage->WS->storage round-trip - render_trampler.py: per-floor map from CompartmentsDatabase cell footprints (rotation solved via overlap check) + doors/hatches from Connections + turret arcs + cargo C1-C8 in game order - docs/MASTER_SERVER.md, docs/TRAMPLER.md; ghidra address-offset bug fixed (no -0x1000)
This commit is contained in:
265
reverse/master_scrape.py
Normal file
265
reverse/master_scrape.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scrape SAND's master server by replaying its protocol (2026-06-15 build) from an external
|
||||
process - no game, no BattlEye interaction (like playfab_scrape.py).
|
||||
|
||||
HANDSHAKE (RE'd from ghidra decompile of GameAssembly.dll + live Player.log; build 23737037):
|
||||
TWO sequential WebSocket connections, both speaking ClientMessage JSON envelopes:
|
||||
envelope = {"Id":<long>, "ClientMessageType":<int>, "Action":<int>, "Message":<string|null>}
|
||||
ClientMessageType: Error=0, Ping=1, Action=2, Event=3 (requests use 2)
|
||||
Action (ClientAction): Login=0, Connect=1, GetCharacters=2, GetStorage=20, GetNews=21,
|
||||
GetCompartmentDefinitions=23, GetDatabaseGuid=26, GetDefaultWalkerBlueprints=28,
|
||||
GetServers=29, GetResearchTree=32, GetShopItems=39, ...
|
||||
`Message` for Login is the LoginRequest serialized as a JSON *string* (nested); for Connect it
|
||||
is the bool isDebug stringified ("False"); for parameterless gets it is null.
|
||||
Serialization: Newtonsoft default -> PascalCase fields, enums as integers, sent as TEXT frames.
|
||||
Replies: a ClientMessage with the same Id; its `Message` is JSON of OperationResult<T> =
|
||||
{"IsSucceed":bool,"Error":string|null,"Status":int|null,"Result":<T>}. Correlate by Id.
|
||||
|
||||
Step 1 /login (no auth header): connect wss://<region>.hologryph.com/gameclient/login,
|
||||
send ClientMessage{Action=Login(0), Message=JSON(LoginRequest{SessionTicket=<PlayFab>,
|
||||
Title=56693, Platform=STEAM(1), ClientVersion=<git hash>})}; reply Result.SessionTicket
|
||||
is the SERVER session ticket. Close.
|
||||
Step 2 /connect (persistent): connect wss://<region>.hologryph.com/gameclient/connect WITH
|
||||
HTTP upgrade header Authorization: <server SessionTicket> ; send Connect(Action=1,
|
||||
Message="False"); then all data ops over this same socket.
|
||||
|
||||
SAFETY: does nothing over the network without --go. Token from reverse/.secrets/playfab_token.json.
|
||||
|
||||
Usage:
|
||||
venv/bin/python reverse/master_scrape.py --selftest # offline
|
||||
venv/bin/python reverse/master_scrape.py # dry run (plan only)
|
||||
venv/bin/python reverse/master_scrape.py --go --data # ARMED: full handshake + data ops
|
||||
"""
|
||||
import asyncio, json, os, argparse, ssl, sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SECRETS = os.path.join(ROOT, "reverse", ".secrets", "playfab_token.json")
|
||||
NAMES_PATH = os.path.join(ROOT, "extracted", "item_names.json")
|
||||
OUTDIR = os.path.join(ROOT, "extracted")
|
||||
|
||||
REGION_BASES = {r: "wss://%s.hologryph.com/gameclient/" % r
|
||||
for r in ("ger", "eus", "westus", "sin", "bra", "aus", "sko", "uae")}
|
||||
MT_ACTION = 2
|
||||
ACTIONS = {"Login": 0, "Connect": 1, "GetCharacters": 2, "SelectCharacter": 4, "GetTrampler": 16,
|
||||
"UpsertTrampler": 17, "GetStorage": 20, "GetNews": 21, "GetCompartmentDefinitions": 23,
|
||||
"GetDatabaseGuid": 26, "GetDefaultWalkerBlueprints": 28, "GetServers": 29,
|
||||
"GetResearchTree": 32, "GetShopItems": 39}
|
||||
PLATFORM_STEAM = 1
|
||||
DATA_OPS = ["GetCompartmentDefinitions", "GetShopItems", "GetResearchTree", "GetServers",
|
||||
"GetDefaultWalkerBlueprints", "GetNews", "GetDatabaseGuid"]
|
||||
USER_OPS = ["GetStorage", "GetCharacters"]
|
||||
|
||||
|
||||
def load_names():
|
||||
try:
|
||||
d = json.load(open(NAMES_PATH))["items"]
|
||||
return {k: (v.get("name") or k) for k, v in d.items()}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def load_token():
|
||||
try:
|
||||
return json.load(open(SECRETS))
|
||||
except Exception as e:
|
||||
return {"_error": str(e)}
|
||||
|
||||
|
||||
def mask(s):
|
||||
return (s[:6] + "..." + s[-4:]) if s and len(s) > 12 else "<none>"
|
||||
|
||||
|
||||
def login_request(tok, version):
|
||||
return {"SessionTicket": tok["SessionTicket"], "Title": tok.get("TitleId", "56693"),
|
||||
"Platform": PLATFORM_STEAM, "ClientVersion": version}
|
||||
|
||||
|
||||
class WS:
|
||||
"""One ClientMessage WebSocket: connect (opt. Authorization header) -> id-correlated requests."""
|
||||
def __init__(self, url, auth=None, insecure=False):
|
||||
self.url = url
|
||||
self.auth = auth
|
||||
self.ssl = ssl.create_default_context()
|
||||
if insecure:
|
||||
self.ssl.check_hostname = False
|
||||
self.ssl.verify_mode = ssl.CERT_NONE
|
||||
self._id = 0
|
||||
self._pending = {}
|
||||
self.events = []
|
||||
self.ws = None
|
||||
|
||||
async def connect(self):
|
||||
import websockets
|
||||
headers = {"Authorization": self.auth} if self.auth else None
|
||||
self.ws = await websockets.connect(self.url, ssl=self.ssl, open_timeout=15,
|
||||
max_size=None, ping_interval=None,
|
||||
additional_headers=headers)
|
||||
self._task = asyncio.create_task(self._recv())
|
||||
|
||||
async def _recv(self):
|
||||
try:
|
||||
async for raw in self.ws:
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8", "replace")
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
self.events.append({"_unparsed": raw[:300]}); continue
|
||||
if msg.get("ClientMessageType") == 1: # Ping -> echo
|
||||
try:
|
||||
await self.ws.send(json.dumps(msg))
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
rid = msg.get("Id")
|
||||
if rid in self._pending and not self._pending[rid].done():
|
||||
self._pending[rid].set_result(msg)
|
||||
else:
|
||||
self.events.append(msg)
|
||||
except Exception as e:
|
||||
for f in self._pending.values():
|
||||
if not f.done():
|
||||
f.set_exception(e)
|
||||
|
||||
async def request(self, action_name, payload=None, raw_message=None, timeout=30):
|
||||
self._id += 1
|
||||
rid = self._id
|
||||
if raw_message is not None:
|
||||
message = raw_message
|
||||
elif payload is not None:
|
||||
message = json.dumps(payload)
|
||||
else:
|
||||
message = None
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
self._pending[rid] = fut
|
||||
await self.ws.send(json.dumps({"Id": rid, "ClientMessageType": MT_ACTION,
|
||||
"Action": ACTIONS[action_name], "Message": message}))
|
||||
reply = await asyncio.wait_for(fut, timeout=timeout)
|
||||
inner = reply.get("Message")
|
||||
if not isinstance(inner, str) or not inner:
|
||||
return inner
|
||||
try:
|
||||
return json.loads(inner)
|
||||
except ValueError:
|
||||
return inner # non-JSON Message (e.g. plain string / empty result)
|
||||
|
||||
async def close(self):
|
||||
if self.ws:
|
||||
await self.ws.close()
|
||||
|
||||
|
||||
def resolve(obj, names):
|
||||
if isinstance(obj, dict):
|
||||
out = {}
|
||||
for k, v in obj.items():
|
||||
out[k] = resolve(v, names)
|
||||
if k in ("DefinitionName", "ItemDefinition", "EpbId") and isinstance(v, str) and v in names:
|
||||
out["_name"] = names[v]
|
||||
return out
|
||||
if isinstance(obj, list):
|
||||
return [resolve(x, names) for x in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def unwrap(msg):
|
||||
"""Replies are either the DTO directly, or an OperationResult{IsSucceed,Result}. Normalize."""
|
||||
if isinstance(msg, dict) and "IsSucceed" in msg and "Result" in msg:
|
||||
return msg["Result"]
|
||||
return msg
|
||||
|
||||
|
||||
def save(op_name, result, names):
|
||||
out = os.path.join(OUTDIR, "master_%s.json" % op_name)
|
||||
json.dump({"_op": op_name, "Result": resolve(result, names)},
|
||||
open(out, "w"), indent=1, ensure_ascii=False, default=str)
|
||||
print(" wrote %s (%s)" % (out, len(result) if isinstance(result, list) else 1))
|
||||
|
||||
|
||||
async def run(args):
|
||||
base = REGION_BASES[args.region]
|
||||
names = load_names()
|
||||
tok = load_token()
|
||||
version = args.client_version or tok.get("ClientVersion", "")
|
||||
|
||||
# --- Step 1: /login (no header) -> server SessionTicket ---
|
||||
print("STEP 1 /login %slogin" % base)
|
||||
lg = WS(base + "login", insecure=args.insecure)
|
||||
await lg.connect()
|
||||
res = await lg.request("Login", payload=login_request(tok, version)) # Message = LoginUserResultDto
|
||||
await lg.close()
|
||||
res = unwrap(res)
|
||||
srv_ticket = res.get("SessionTicket") if isinstance(res, dict) else None
|
||||
if not srv_ticket:
|
||||
print(" login failed; reply:", json.dumps(res)[:300]); return
|
||||
user = res.get("User", {})
|
||||
print(" Login OK. User.Id=%s name=%s -> server ticket %s" %
|
||||
(user.get("Id"), user.get("DisplayName"), mask(srv_ticket)))
|
||||
save("Login", res, names)
|
||||
|
||||
# --- Step 2: /connect (Authorization header) -> persistent socket ---
|
||||
print("STEP 2 /connect %sconnect (Authorization: <server ticket>)" % base)
|
||||
cx = WS(base + "connect", auth=srv_ticket, insecure=args.insecure)
|
||||
await cx.connect()
|
||||
cop = await cx.request("Connect", raw_message="False")
|
||||
print(" Connect ->", json.dumps(cop)[:160] if cop is not None else "(no body)")
|
||||
|
||||
ops = (DATA_OPS if args.data or not args.user else []) + (USER_OPS if args.user else [])
|
||||
for name in ops:
|
||||
try:
|
||||
res = unwrap(await cx.request(name))
|
||||
n = len(res) if isinstance(res, list) else (1 if res else 0)
|
||||
print(" %-28s -> %s entr%s" % (name, n, "y" if n == 1 else "ies"))
|
||||
if res is not None:
|
||||
save(name, res, names)
|
||||
except Exception as e:
|
||||
print(" %-28s ERROR %s: %s" % (name, type(e).__name__, e))
|
||||
await cx.close()
|
||||
print("done. events: %d" % len(cx.events))
|
||||
|
||||
|
||||
def print_plan(args):
|
||||
base = REGION_BASES[args.region]
|
||||
tok = load_token()
|
||||
ver = args.client_version or tok.get("ClientVersion", "<VERSION>")
|
||||
print("=== DRY RUN (no network). Pass --go to connect. ===")
|
||||
print("STEP 1 connect %slogin (no auth header)" % base)
|
||||
lr = login_request({"SessionTicket": "<PLAYFAB_TICKET>", "TitleId": tok.get("TitleId", "56693")}, ver)
|
||||
print(" send {\"Id\":1,\"ClientMessageType\":2,\"Action\":0,\"Message\":%s}" % json.dumps(json.dumps(lr)))
|
||||
print(" (real PlayFab ticket %s) -> reply Result.SessionTicket = server ticket" %
|
||||
mask(tok.get("SessionTicket", "")))
|
||||
print("STEP 2 connect %sconnect HEADER Authorization: <server ticket>" % base)
|
||||
print(" send {\"Id\":1,\"ClientMessageType\":2,\"Action\":1,\"Message\":\"False\"}")
|
||||
for i, name in enumerate(DATA_OPS, 2):
|
||||
print(" then {\"Id\":%d,\"ClientMessageType\":2,\"Action\":%d,\"Message\":null} (%s)"
|
||||
% (i, ACTIONS[name], name))
|
||||
print("\nNothing sent.")
|
||||
|
||||
|
||||
def selftest():
|
||||
lr = login_request({"SessionTicket": "T", "TitleId": "56693"}, "abc")
|
||||
assert lr == {"SessionTicket": "T", "Title": "56693", "Platform": 1, "ClientVersion": "abc"}, lr
|
||||
assert ACTIONS["Login"] == 0 and ACTIONS["Connect"] == 1 and ACTIONS["GetCompartmentDefinitions"] == 23
|
||||
names = {"item_coinCrown": "Crowns"}
|
||||
assert resolve({"ItemDefinition": "item_coinCrown"}, names)["_name"] == "Crowns"
|
||||
print("selftest OK: login payload, actions, name resolution")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="SAND master-server replay (two-socket ClientMessage)")
|
||||
ap.add_argument("--region", default="ger", choices=list(REGION_BASES))
|
||||
ap.add_argument("--go", action="store_true")
|
||||
ap.add_argument("--data", action="store_true")
|
||||
ap.add_argument("--user", action="store_true")
|
||||
ap.add_argument("--client-version", default="")
|
||||
ap.add_argument("--insecure", action="store_true")
|
||||
ap.add_argument("--selftest", action="store_true")
|
||||
args = ap.parse_args()
|
||||
if args.selftest:
|
||||
selftest(); return
|
||||
if not args.go:
|
||||
print_plan(args); return
|
||||
asyncio.run(run(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user