From 5946e0910bb2bc4cfc93d0b83d64c35056443e48 Mon Sep 17 00:00:00 2001 From: DownloadPizza Date: Fri, 12 Jun 2026 01:17:07 +0200 Subject: [PATCH] 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 --- docs/BACKEND_PLAYFAB.md | 217 ++++++++++++++++++++++++++++++++++++++++ docs/SALES_VALUE.md | 56 +++++++++++ docs/WEAPON_DAMAGE.md | 4 + 3 files changed, 277 insertions(+) create mode 100644 docs/BACKEND_PLAYFAB.md create mode 100644 docs/SALES_VALUE.md diff --git a/docs/BACKEND_PLAYFAB.md b/docs/BACKEND_PLAYFAB.md new file mode 100644 index 0000000..f33e9bc --- /dev/null +++ b/docs/BACKEND_PLAYFAB.md @@ -0,0 +1,217 @@ +# 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 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** (`.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`, `CrownPrice`, `T1/T2/T3_MetalPrice` | +| `ItemDto` | PlayFab Economy | `DefinitionName`, `SellPrice: List`, `Outfitable`, `IsLarge`, `IsExcess` | +| `ShopItemDto` | PlayFab Economy | `DefinitionName`, `Amount`, `BuyPrice: List` | +| `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://.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://.playfabapi.com/Client/LoginWithSteam` + `{ "TitleId": "", "SteamTicket": "", "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 `.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). diff --git a/docs/SALES_VALUE.md b/docs/SALES_VALUE.md new file mode 100644 index 0000000..651046d --- /dev/null +++ b/docs/SALES_VALUE.md @@ -0,0 +1,56 @@ +# Item sales / buy value — static map of the model (for tracking across updates) + +Goal: locate an item's **sell value / buy price** in the static files. + +**Status:** same situation as [weapon damage](WEAPON_DAMAGE.md). The price *model* is fully +mapped statically (below). The actual *numbers* are **not** in any client asset — they live in +the **PlayFab Economy catalog** (server-side) and reach the client as network DTOs. See +[BACKEND_PLAYFAB.md](BACKEND_PLAYFAB.md) for how to acquire them at runtime without touching the +game process (BattlEye-safe). + +## Price model (all static, in `il2cpp/dump.cs`) + +A price is **"N units of a currency item"**, not a bare number: + +| Type | Fields | Role | +|---|---|---| +| `PriceDto` | `string ItemDefinition`, `int Amount` | one price leg = *Amount* of currency-item *ItemDefinition* | +| `ItemDto` | `List SellPrice` | what the item **sells** for | +| `ShopItemDto` | `string DefinitionName`, `int Amount`, `List BuyPrice` | what a shop **sells** to you / its **buy** cost | +| `ShopItemSlotModel` (UI) | `int price`, `int requiredCurrencyAmount`, `string priceCurrencyItemName`, `bool isForSale`, `bool hasPrice`, `bool isTradable` | tooltip values, read off the DTO | +| `PriceDataComponent : BaseLongValueComponent` (TypeDefIndex 5822, `IItemContextComponent`) | `long value @+0x10` | per-item price as an Entitas component — structurally identical to the `DamageXxxDataComponent` pattern | +| `TransactionCurrencyData` | `Sprite sprite`, `int balance`, `Color color` | the wallet/currency shown in the store UI | +| `PawnShopDeliveryRewardDataComponent` | `string rewardItem`, `int rewardCount` | pawn-shop turn-in reward | + +The currency item is **Crowns** (`item_coinCrown`) — the in-game money (the test conveyor +`1x item_wineBox → 1000x item_coinCrown` mints it). *(Currency identity inferred from the item +registry + that it's the only "coin" item; not yet confirmed against a live `PriceDto`.)* + +Store plumbing: `StoreBuyContainer`, `StoreSellContainer`, `LobbyStoreModule`, `StoreData`, +`ShopInventoryContainer` — all under `Hologryph.Sand.Client.MainMenu.Ui.Store` (the **lobby** +store, i.e. between-runs, not in-world). + +## The numbers are not in any asset (verified, same three ways as damage) + +| Check | Result | +|---|---| +| `PriceDataComponent` across all 1446 EntityBlueprints (`bundle/component_census.py Price`) | **0** — not authored on any item | +| `PawnShop*` across all 1446 blueprints | 0 | +| `configuration_assets_all.bundle` MonoBehaviour classes (15 total) | no store/price/economy/shop config — only world/biome/loot/contract configs | +| `CheatItemDefinitionsData` item defs | `{Name, Type, StorageStack}` — no price field | + +The giveaway is the naming: prices travel as **`...Dto`** (data-transfer objects) through +`LobbyStoreModule` — they are delivered by the backend, not baked into the client. So sales value +is **server-authoritative**, exactly like weapon damage. + +## Re-deriving / tracking + +- Diff the model across patches: re-locate `PriceDto`, `ItemDto.SellPrice`, `ShopItemDto.BuyPrice`, + `PriceDataComponent` by signature. +- Get the actual numbers: query the PlayFab catalog — see + [BACKEND_PLAYFAB.md](BACKEND_PLAYFAB.md). The catalog's `PriceOptions` are the source of these + `PriceDto`s. + +One adjacent thing that **is** static: `WorldContractsConfig` (delivery-contract rewards) is +authored in `configuration_assets_all.bundle`, so those reward amounts are extractable from the +bundle directly (a different economy than the store sell price). diff --git a/docs/WEAPON_DAMAGE.md b/docs/WEAPON_DAMAGE.md index 14ce29e..b511c4e 100644 --- a/docs/WEAPON_DAMAGE.md +++ b/docs/WEAPON_DAMAGE.md @@ -108,6 +108,10 @@ Findings, all consistent with the capstone analysis above: the calc itself), and the rich calc `d__12` factory (0x4BAC460) has **zero** callers. So the damage *computation* is not exercised in client-reachable code. +> **Backend identified:** that server is **Azure PlayFab** (Economy v2). The runtime route to +> these numbers — and whether a base value is catalog-authored in `DisplayProperties` — is in +> [BACKEND_PLAYFAB.md](BACKEND_PLAYFAB.md), along with how to pull it without tripping BattlEye. + **Conclusion:** the per-weapon damage numbers are **server-authoritative runtime data**. They live as Entitas component `value` floats created/assigned by server-side code through fully-generic dispatch (no static class/index/string anchor) and reach the client only via