Files
SandTools/docs/BACKEND_PLAYFAB.md
DownloadPizza 5946e0910b docs: item sales value + PlayFab/master-server backend map
- SALES_VALUE.md: price model (PriceDto/ItemDto.SellPrice/PriceDataComponent), server-authoritative
- BACKEND_PLAYFAB.md: backend = PlayFab Economy + ws:// master server (per-region);
  observed no-playtest boot behavior, capture findings, OnRegionsLoadFailed dialog path
- WEAPON_DAMAGE.md: cross-link to backend doc
2026-06-12 01:17:07 +02:00

13 KiB

Backend = PlayFab (Azure) — acquiring server-authoritative data

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 succeedingGetRegionsAsync 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) and item prices (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, ServerDtoWorldEndpointData, 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 PriceDtos the client shows (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:

  • TitleIdset 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).