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:
DownloadPizza
2026-06-16 00:35:17 +02:00
parent 3df0797acc
commit fc6b270fa8
29 changed files with 61574 additions and 0 deletions

265
reverse/master_scrape.py Normal file
View 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()