#!/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":, "ClientMessageType":, "Action":, "Message":} 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 = {"IsSucceed":bool,"Error":string|null,"Status":int|null,"Result":}. Correlate by Id. Step 1 /login (no auth header): connect wss://.hologryph.com/gameclient/login, send ClientMessage{Action=Login(0), Message=JSON(LoginRequest{SessionTicket=, Title=56693, Platform=STEAM(1), ClientVersion=})}; reply Result.SessionTicket is the SERVER session ticket. Close. Step 2 /connect (persistent): connect wss://.hologryph.com/gameclient/connect WITH HTTP upgrade header Authorization: ; 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 "" 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: )" % 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", "") print("=== DRY RUN (no network). Pass --go to connect. ===") print("STEP 1 connect %slogin (no auth header)" % base) lr = login_request({"SessionTicket": "", "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: " % 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()