- 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)
227 lines
14 KiB
Markdown
227 lines
14 KiB
Markdown
# Backend = PlayFab (Azure) — acquiring server-authoritative data
|
|
|
|
> **CORRECTIONS (2026-06-15, from a live playtest + code-verified RE — these override claims below):**
|
|
> - **PlayFab is auth-only for this title.** Economy v2 is disabled (`403 "Catalog is not enabled
|
|
> for this title"`); the classic catalog/inventory are empty. The economy (compartment stats,
|
|
> prices, research) is **not** in PlayFab — it's on the master server. PlayFab does
|
|
> `LoginWithSteam` + profile + one `TitleData` walker blueprint (`TramplerBlueprint3`). TitleId = **56693**.
|
|
> - **Master server is NOT gRPC/SignalR and NOT cleartext `ws://:80`.** It's a custom per-call
|
|
> WebSocket RPC over **`wss://:443`**, JSON, auth = PlayFab `SessionTicket` as a query param of the
|
|
> `Login` op. Full spec + scraper: **[MASTER_SERVER.md](MASTER_SERVER.md)** (`reverse/master_scrape.py`).
|
|
|
|
## Observed boot behavior — no active playtest (GROUND TRUTH, reported by user)
|
|
|
|
When no playtest is running, the exact, observed sequence is:
|
|
|
|
1. Click **Sand** on the desktop.
|
|
2. Game starts.
|
|
3. A dialog appears: **"no servers …"** (no-servers message).
|
|
4. Click **OK**.
|
|
5. **Infinite loading hourglass** (never proceeds).
|
|
|
|
This is the authoritative behavior. The dialog appears at startup, before any manual login step.
|
|
**How the client determines "no servers" at this point is NOT established** — see the capture note
|
|
below; do not assume a mechanism that isn't evidenced.
|
|
|
|
### Network capture during a no-playtest session (`capture.pcapng`, 105 s)
|
|
|
|
Every outbound connection the `Sand` process made, in order (TCP + UDP/QUIC, full enumeration):
|
|
|
|
| t | host | transport |
|
|
|---|---|---|
|
|
| +0.8 s (DNS) / +8.9 s (connect) | `cdn.battleye.com` | TCP 443 — **first game network activity** |
|
|
| ~+5 s | `sand1.battleye.com` | UDP 3763/3764 |
|
|
| +16 s / +52 s | `*.steamserver.net`, `api.steampowered.com` | TCP 443 + Steam UDP 27xxx |
|
|
| +70 s | `sandconfigstorage.blob.core.windows.net` | TCP 443 (heavy parallel download) |
|
|
|
|
**Not present in this capture, over any protocol:** `*.playfabapi.com` and `*.hologryph.com`
|
|
(no DNS, no TCP, no UDP/QUIC, no port-80 WebSocket).
|
|
|
|
**Second capture (`cap2.pcapng`, DNS flushed, 79 s):** identical picture — BattlEye, Steam,
|
|
config blob, and **still no `playfabapi.com` / no `hologryph.com`** over TCP/UDP/QUIC. Flushing DNS
|
|
changed nothing, confirming the client never even *resolves* the game backend in this state.
|
|
|
|
**Steam ruled out as the server-list source (tested, refuted).** Hypothesis was that the "no
|
|
servers" verdict came from Steam's server browser. Against it:
|
|
- The game does **not** implement Steam's server-browser callback (`ISteamMatchmakingServerListResponse`)
|
|
— only the unused Steamworks.NET binding exposes it.
|
|
- **Zero** A2S/master-server query packets (`0xFFFFFFFF`-headed UDP) in either capture.
|
|
- Steam's actual role is **auth**: `SteamPlatformAdapter : IPlatformAuthTokenProvider, IPlatformPlayFabLogin`
|
|
— it supplies the ticket and bridges into PlayFab login. The `api.steampowered.com` hit is ticket
|
|
retrieval, not server discovery. (The 27xxx UDP is Steam SDR relay pinging, unrelated.)
|
|
|
|
### The dialog and its code path (RE'd)
|
|
|
|
The "no servers" dialog is **`DialogPopups/OnRegionsLoadFailed`**:
|
|
> "Could not connect to the server or there are no regions available. Please check your network…"
|
|
|
|
Code path (from `il2cpp/dump.cs` + Ghidra decompile of the state machines):
|
|
|
|
```
|
|
WelcomeState.OnEnter
|
|
→ WelcomeState.EnsureRegionsLoadedRoutine()
|
|
→ RegionsModule.FetchRegionsAsync()
|
|
→ ClientMasterServerNetwork.GetRegionsAsync(clientVersion)
|
|
← awaits the master-server LOGIN pipeline:
|
|
Login() → LoginProcessResult { bool IsSuccess; SandErrorStatus? Error }
|
|
→ on failure: WelcomeState.OnRegionsLoadFailed() → shows the dialog
|
|
```
|
|
|
|
So the region list is **gated on the master-server login succeeding** — `GetRegionsAsync` awaits
|
|
`Login()` first. The single dialog text covers both "couldn't connect" and "no regions," because
|
|
both are the same failure path.
|
|
|
|
### What the captures prove about that path
|
|
|
|
Both captures (incl. DNS-flushed cap2), checked thoroughly for failures: **no NXDOMAIN, every TCP
|
|
SYN got a SYN-ACK, no ICMP-unreachable, RSTs are normal teardowns.** There is **no connection
|
|
attempt — successful OR failed — to PlayFab or the master server.** So the login/region-fetch is
|
|
**not failing on the network; it is returning before any socket is opened.**
|
|
|
|
### Still unresolved (do not guess past this)
|
|
|
|
Two possibilities remain, not separable from current data:
|
|
- **(a)** the `Login()` pipeline fails *locally/early* (a precondition before it dials out), or
|
|
- **(b)** the capture window ended before `WelcomeState` ran the login attempt.
|
|
|
|
The clean disambiguator is one observation: **did the "no servers" dialog appear while the capture
|
|
was still recording?** If yes → (a) (and the wanted data is simply never requested in this state →
|
|
needs servers online). If no → (b) (needs a capture spanning the dialog). The `Login` MoveNext
|
|
(RVA `0xBE1DE0`) decompiles but is heavily obfuscated; pinning the exact local gate needs more RE.
|
|
|
|
Earlier in-thread claims that login is gated behind a welcome-screen click were **speculation and
|
|
are retracted** — the observed flow shows the dialog at startup.
|
|
|
|
---
|
|
|
|
The server-authoritative values we can't get statically — **weapon damage**
|
|
([WEAPON_DAMAGE.md](WEAPON_DAMAGE.md)) and **item prices** ([SALES_VALUE.md](SALES_VALUE.md)),
|
|
plus inventory, wallet, lobby, matchmaking — all run through **Microsoft Azure PlayFab**. The
|
|
store/economy specifically is **PlayFab Economy v2** (catalog + inventory). This means the data is
|
|
reachable through PlayFab's **public, documented REST API** from a process *outside* the game —
|
|
which is the key to getting it **without any BattlEye interaction** (BattlEye guards the game
|
|
process; it does not police your DNS, your OS cert store, or an unrelated HTTPS client).
|
|
|
|
## Evidence it's PlayFab (all from `il2cpp/dump.cs` + metadata strings)
|
|
|
|
- Namespaces: `PlayFab.ClientModels`, `PlayFab.EconomyModels`, `PlayFab.ProfilesModels`,
|
|
`PlayFab.MultiplayerModels`, `PlayFab.AuthenticationModels`, …
|
|
- Host literal `playfabapi.com` (`PlayFabSettings.DefaultPlayFabApiUrl = "playfabapi.com"`);
|
|
SDK = `UnitySDK-2.196.240621`.
|
|
- REST path strings present: `/Catalog/SearchItems`, `/Catalog/GetItems`, `/Catalog/GetItem`,
|
|
`/Catalog/GetItemContainers`, `/Inventory/GetInventoryItems`, `/Inventory/PurchaseInventoryItems`,
|
|
`/Inventory/SubtractInventoryItems`, `/Inventory/GetTransactionHistory`,
|
|
`/Inventory/RedeemSteamInventoryItems`, `/Lobby/*`, `/Match/*`, `/Profile/*`, `/Group/*`.
|
|
- Auth: `LoginWithSteam` (`SteamTicket` = Steam session ticket → PlayFab `SessionTicket` +
|
|
`GetEntityToken`).
|
|
|
|
## Two backends — which data rides which pipe
|
|
|
|
Not everything comes from PlayFab. There are **two** server channels, and any given DTO is on one
|
|
of them:
|
|
|
|
1. **PlayFab REST** (`<TitleId>.playfabapi.com`, JSON over HTTPS): auth (`LoginWithSteam`), the
|
|
**Economy** catalog + inventory + wallet, possibly `GetTitleData`/`ExecuteCloudScript` config
|
|
blobs. → `ItemDto` (SellPrice), `ShopItemDto` (BuyPrice), prices, inventory.
|
|
2. **Master server** (`ClientMasterServerNetwork`, separate `MasterServerPathUrl`): the gameplay
|
|
config + session DTOs. Transport is **gRPC / SignalR / WebSocket** (binary protobuf, *not* JSON).
|
|
→ `CompartmentDefinitionDto`, `WalkerBlueprintDto`, `ServerDto`→`WorldEndpointData`, research,
|
|
friends, etc.
|
|
3. **In-match game server** (realtime ECS snapshot replication, the `…CopyTo` netcode): live applied
|
|
damage and current HP during combat. Handheld weapon damage is computed here at hit time and is
|
|
in **no** pre-match DTO. Hardest channel; most anticheat-adjacent.
|
|
|
|
### Stat-bearing DTOs (what each carries)
|
|
|
|
| DTO | Channel | Stat fields |
|
|
|---|---|---|
|
|
| `CompartmentDefinitionDto` | master server | `EpbId`, `Weight`, `VisualWeight`, **`HP`**, `Properties: List<CompartmentProperty{Key,Value}>`, `CrownPrice`, `T1/T2/T3_MetalPrice` |
|
|
| `ItemDto` | PlayFab Economy | `DefinitionName`, `SellPrice: List<PriceDto>`, `Outfitable`, `IsLarge`, `IsExcess` |
|
|
| `ShopItemDto` | PlayFab Economy | `DefinitionName`, `Amount`, `BuyPrice: List<PriceDto>` |
|
|
| `PriceDto` | both | `ItemDefinition` (currency item), `Amount` |
|
|
| `CompartmentBlueprintDto` / `WalkerBlueprintDto` | master server | *structural* (placement, hashes) — player's build, not stat definitions |
|
|
|
|
So **compartment stats (incl. mounted-turret/weapon compartments via `Properties`) are acquirable**,
|
|
but from the master server's **gRPC** stream — capture it (MITM, your hosts+cert plan works at the
|
|
transport layer) and **decode the protobuf** against these DTO layouts. The pure-REST replay
|
|
(Option A) does **not** reach the master server; that's MITM-capture only. Much of the compartment
|
|
data is *also* already in the static EPB blueprints (`HealthDataComponent` HP, `PhysicsDataComponent`
|
|
mass) — check there first; the DTO adds the authoritative `Properties` + prices.
|
|
|
|
## Where the prices live
|
|
|
|
PlayFab Economy v2 **Catalog** items carry `PriceOptions`; those are exactly the `PriceDto`s the
|
|
client shows ([SALES_VALUE.md](SALES_VALUE.md)). So `Catalog/SearchItems` (paged) or
|
|
`Catalog/GetItems` (by id) returns every item **with its prices** — plus `DisplayProperties`
|
|
(arbitrary per-item JSON, worth dumping: base stats *may* live there, unconfirmed). Damage may or
|
|
may not be in `DisplayProperties`; it's computed server-side at hit time, but a base value could be
|
|
catalog-authored — **check the catalog dump before concluding**.
|
|
|
|
## The request URL (and how to redirect it)
|
|
|
|
`PlayFabSettings.GetFullUrl(apiCall, …)` builds `https://{TitleId}{VerticalName}.playfabapi.com` +
|
|
`apiCall`. Relevant knobs on `PlayFabSettings` / `PlayFabApiSettings`:
|
|
|
|
- `TitleId` — **set at runtime by game code, not a static literal** (the `40621` near the host in
|
|
metadata is a red herring — it's part of the SDK version `2.196.240621`). Get it from a captured
|
|
request host (`https://<TitleId>.playfabapi.com/…`) once traffic is visible, or by decompiling
|
|
the PlayFab bootstrap (see TODO below).
|
|
- `ProductionEnvironmentUrl` — if set to a full `http(s)://…`, overrides the host entirely.
|
|
- `LocalApiServer` (`_localApiServer`) — local/test server override.
|
|
- `GetLocalSettingsFileProperty(propertyKey)` reads **`playfab.local.settings.json`** — the PlayFab
|
|
SDK's on-disk override file (can carry `TitleId`, `ProductionEnvironmentUrl`, etc.).
|
|
**The code path is compiled into this build; whether the shipped IL2CPP *player* actually invokes
|
|
the local-settings read at startup is UNVERIFIED** (in the Unity SDK it's historically
|
|
editor/GSDK-oriented). Confirm before relying on it.
|
|
|
|
## Why the HTTPS proxy "didn't show up"
|
|
|
|
The PlayFab Unity SDK's default transport is **`UnityWebRequest`**, which on Windows uses its own
|
|
HTTP stack and **does not honor the WinINET / system proxy** (nor `HTTP_PROXY`/`HTTPS_PROXY` env
|
|
vars — those only affect .NET `HttpClient`). The Steam auth ticket *was* visible because the **Steam
|
|
client** fetches it over WinHTTP, which respects the system proxy. So the silence is explained by
|
|
proxy-bypass; no evidence of TLS **pinning** was found (no custom `ServerCertificateValidation`
|
|
hook surfaced), though that wasn't exhaustively audited.
|
|
|
|
## Options to acquire the data (ranked by BattlEye safety)
|
|
|
|
**A. External PlayFab REST client — RECOMMENDED, zero game-process interaction.**
|
|
Replay the login yourself and call the catalog. This is *not* "reimplementing the protocol" — it's
|
|
the public, documented PlayFab REST API (stable, SDK'd):
|
|
1. Get a Steam session ticket (you already capture this) — or mint one via Steamworks
|
|
`GetAuthSessionTicket` for AppID of the playtest.
|
|
2. `POST https://<TitleId>.playfabapi.com/Client/LoginWithSteam`
|
|
`{ "TitleId": "<TitleId>", "SteamTicket": "<hex ticket>", "CreateAccount": false }`
|
|
→ `SessionTicket` + `EntityToken`.
|
|
3. `POST /Catalog/SearchItems` (header `X-EntityToken`) with `{ "Count": 50, "Filter": "" }`,
|
|
page via `ContinuationToken` → full catalog with `PriceOptions` + `DisplayProperties`.
|
|
(`/Inventory/GetInventoryItems` for your wallet/items; `/Inventory/GetTransactionHistory` for
|
|
realized prices.)
|
|
BattlEye never sees this — it's a separate process talking to Microsoft. Only missing input is
|
|
the **TitleId**.
|
|
|
|
**B. Transparent network capture (network-only, no process touch).**
|
|
A normal proxy failed because UnityWebRequest ignores it. Instead redirect at the network layer:
|
|
`hosts`/DNS-map `<TitleId>.playfabapi.com` → your MITM, and install your MITM **root CA in the
|
|
Windows cert store** (UnityWebRequest validates against the OS store, so once your root is trusted
|
|
it'll accept the interception). This reads the *real* client's traffic (so TitleId + tokens +
|
|
catalog all fall out). BattlEye does not inspect your DNS or OS cert store, so the game process is
|
|
untouched. *(Sportsmanlike caveat: only capture your own session; don't replay others' tokens.)*
|
|
|
|
**C. `playfab.local.settings.json` redirect — only if B/A are blocked.**
|
|
Drop the SDK's override file next to the game pointing `ProductionEnvironmentUrl`/`LocalApiServer`
|
|
at your endpoint. Cheapest *if* the player build reads it (unverified, above), but it **adds a file
|
|
the game reads at startup** — the only option here with any game-side footprint, so prefer A or B.
|
|
|
|
## TODO / open items
|
|
|
|
- **TitleId**: not a static literal. Quick paths: read it from a captured request (A/B), or decompile
|
|
`PlayFabSettings.set_TitleId` (RVA `0x41ABCD0`) callers / the game's PlayFab bootstrap in Ghidra to
|
|
recover the assigned constant. (No direct call-rel32 xref to the static setter was found — it's
|
|
likely assigned via `staticSettings.TitleId` (virtual `0x82AB90`) or a vtable/indirect store, so
|
|
use the decompiler, not a flat xref scan.)
|
|
- **DisplayProperties dump**: once the catalog is in hand, check whether base damage / stats are
|
|
catalog-authored there (don't assume).
|
|
- **Currency confirmation**: verify `PriceDto.ItemDefinition` for store items resolves to
|
|
`item_coinCrown` (Crowns).
|