- .gitignore: ignore /extracted/ (regenerable game data) and /tmp/ (scratch) - git rm --cached the 38 extracted/ files: untracked but left on disk, not deleted - master_scrape.py: add GetExpedition=7 to ACTIONS (was missing; pulls ExpeditionDto.Trampler) - docs: mark master-server /connect blocker cleared 2026-06-16 (server back up); server-side upsert hash validation remains untested (live re-test not yet run)
266 lines
11 KiB
Python
266 lines
11 KiB
Python
#!/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, "GetExpedition": 7, "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()
|