Compare commits
5 Commits
5946e0910b
...
608ea295a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608ea295a0 | ||
|
|
bd01d6753a | ||
|
|
aa7425221f | ||
|
|
fc6b270fa8 | ||
|
|
3df0797acc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ dist/
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
/reverse/.secrets/
|
||||||
|
|||||||
159
CLAUDE.md
Normal file
159
CLAUDE.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this repo is
|
||||||
|
|
||||||
|
Reverse-engineering / data-mining toolkit for the game **SAND** (Hologryph, Unity 6000.0.40f1,
|
||||||
|
IL2CPP). It extracts server-authoritative and static game data from three sources, and reads/writes
|
||||||
|
the game's local walker save files. There is no application to build — everything is standalone
|
||||||
|
Python scripts run against game files, network captures, or live servers.
|
||||||
|
|
||||||
|
The four data sources, and which tools own them:
|
||||||
|
- **Unity asset bundles** (static config: items, recipes, loot, islands) → `bundle/`
|
||||||
|
- **Master server** `wss://<region>.hologryph.com/gameclient/` (economy, walker blueprints, research) → `reverse/master_scrape.py`
|
||||||
|
- **PlayFab** (Azure; **auth-only** for this title — Economy/catalog disabled) → `reverse/playfab_scrape.py`
|
||||||
|
- **`.wbt` walker save files** (local, on disk) → `walker/`
|
||||||
|
|
||||||
|
## Working rules (from operator memory — follow these)
|
||||||
|
|
||||||
|
- **Data only, never heuristics.** Do not invent rules or fill gaps with plausible assumptions.
|
||||||
|
Derive every value from game files, decompiled code, or captured payloads — or ask. (An invented
|
||||||
|
"guns point outward" rule and a guessed rotation→facing mapping both produced wrong results.)
|
||||||
|
- **Report what the data shows, not inferences as fact.** Don't jump to conclusions.
|
||||||
|
- **No polling wait-loops.** Use background tasks + wakeup notifications; don't `sleep`-poll for completion.
|
||||||
|
- **Don't hammer the live server.** It is a real playtest backend. Warn the operator *before* any
|
||||||
|
action that makes repeated/abnormal connections. BattlEye is active in the game — all scraping is
|
||||||
|
done **outside** the game process (replayed protocol / captures / REST), never via injection.
|
||||||
|
|
||||||
|
## Environment & how to run
|
||||||
|
|
||||||
|
- Use the project venv: **`venv/bin/python <script>`** (has `UnityPy`, `bson`/pymongo, `scapy`, `Pillow`, `websockets`).
|
||||||
|
- Symlinks (git-ignored, machine-specific — repoint if the machine changes):
|
||||||
|
- `bundles/` → game `StreamingAssets/aa/StandaloneWindows64/` (35 bundles, ~6.8 GB)
|
||||||
|
- `Walkers/` → `…/LocalLow/Hologryph/SAND/Data/Walkers/` (live `.wbt` saves)
|
||||||
|
- Game install referenced by `bundle/` tools: `/mnt/d/SteamLibrary/steamapps/common/Sand Playtest`
|
||||||
|
(`GameAssembly.dll` + `Sand_Data/il2cpp_data/Metadata/global-metadata.dat`).
|
||||||
|
- IL2CPP source of truth: `il2cpp/dump.cs` (Il2CppDumper output — signatures/RVAs only, no method
|
||||||
|
bodies). `il2cpp/`, `ghidra/`, `snapshots/`, `bundles`, `Walkers`, `reverse/.secrets/` are
|
||||||
|
git-ignored (large/regenerable/secret). Live PlayFab token: `reverse/.secrets/playfab_token.json`.
|
||||||
|
- **Game runtime data dir** (`%USERPROFILE%\AppData\LocalLow\Hologryph\SAND`, here
|
||||||
|
`/mnt/c/Users/DownloadPizza/AppData/LocalLow/Hologryph/SAND/`) holds:
|
||||||
|
- **`Player.log`** — Unity log; check it to see what the client did (walker file reads:
|
||||||
|
`[FS_STANDALONE] ReadAllFilesAsync … Path: …/Data/Walkers`; master-server handshake:
|
||||||
|
`[MasterServer] … Login / Connection failed to /connect`). `Player-prev.log` = previous run.
|
||||||
|
- **`Data/Walkers/*.wbt`** — the live walker saves (the `Walkers/` symlink points here).
|
||||||
|
|
||||||
|
## The `.wbt` walker save format (current focus)
|
||||||
|
|
||||||
|
Envelope (RE'd from `XorCryptography.Encrypt`, verified byte-exact on all 5 local walkers):
|
||||||
|
```
|
||||||
|
save: Newtonsoft-BSON -> XOR encrypt -> gzip
|
||||||
|
load: gunzip -> XOR decrypt -> Newtonsoft-BSON parse
|
||||||
|
```
|
||||||
|
- XOR key (current build): `70 DD 1F 2A 0B 4A` (6 bytes), applied **per 0xA000-byte chunk** with the
|
||||||
|
key index reset to 0 at each chunk boundary: `decoded[i] = raw[i] XOR KEY[(i % 0xA000) % 6]`.
|
||||||
|
If a game update changes the key, recover it with no RE via `walker/recover_key.py`.
|
||||||
|
- `pymongo`'s `bson.encode` reproduces Newtonsoft.Bson byte-for-byte, so decode→encode is identity.
|
||||||
|
|
||||||
|
### The 5 hashes (a `.wbt` is a serialized `WalkerBlueprintDto`)
|
||||||
|
|
||||||
|
All = `MD5(UTF8(JsonConvert.SerializeObject(obj))).hexUPPER` — Newtonsoft compact JSON: no whitespace,
|
||||||
|
PascalCase, **member declaration order** (do NOT sort keys), nulls included, **enums as NAME strings**.
|
||||||
|
|
||||||
|
| Hash | Scope | Offline-computable? |
|
||||||
|
|------|-------|---------------------|
|
||||||
|
| `CompartmentsHash` | top-level: `MD5(JSON(Compartments list))` | **YES** — `walker/walker_hashes.py` |
|
||||||
|
| `ConnectionsHash` | top-level: `MD5(JSON(Connections list))` | **YES** — `walker/walker_hashes.py` |
|
||||||
|
| `CompartmentHash` | per-part: placement from CompartmentsDatabase | **YES** — constant per EpbId+placement |
|
||||||
|
| `DefinitionsHash` | top-level: `MD5(JSON(Compartments→CompartmentDefinitionDto))` | **NO** — server-sourced |
|
||||||
|
| `DefinitionHash` | per-part: `MD5(JSON(CompartmentDefinitionDto))` | **NO** — server-sourced |
|
||||||
|
|
||||||
|
The two **Definition** hashes hash the rich server-side `CompartmentDefinitionDto`, which is **not**
|
||||||
|
in the blueprint and **not** equal to the server's `GetCompartmentDefinitions` pricing DTO. They can
|
||||||
|
only be **harvested** (every part placed in-game writes them into the save) — see
|
||||||
|
`extracted/definition_hashes_known.json` (~18/126 parts) and `walker/harvest_hashes.py`. When editing
|
||||||
|
offline, `build_wbt.py pack` recomputes the two Compartment* hashes and **copies/reuses** the
|
||||||
|
Definition* hashes from the source — correct as long as the *set of part definitions* is unchanged;
|
||||||
|
it raises if you add a part whose `DefinitionHash` has never been harvested.
|
||||||
|
|
||||||
|
**Hash lifecycle (verified live 2026-06-16 — see `docs/TRAMPLER.md`):** the client **recomputes all 5
|
||||||
|
hashes on save** from its own database (same build ⇒ byte-identical hashes; no per-walker secret —
|
||||||
|
plain unsalted MD5, they're integrity/version-staleness markers, **not** security). Wiping any/all
|
||||||
|
hashes is harmless: a walker with blank hashes still **loads, lists, and opens in the editor**, and
|
||||||
|
**one in-editor save regenerates everything** (and mints a new file UUID + `UniqueId`). The `VERSION`
|
||||||
|
flag in `Player.log`'s `CheckValidBlueprint: ERRORS {0}, VERSION:{1}` (=`WalkerBlueprintContainer
|
||||||
|
.ValidateVersion`, which recomputes against the *current* DB/definitions) tracks only the **structural**
|
||||||
|
hashes (Compartments/Connections); Definition hashes don't affect client validation. Server-side upsert
|
||||||
|
validation is untested (blocked by master-server `/connect` errors during the playtest).
|
||||||
|
|
||||||
|
Enum tables (from `dump.cs`): `ConnectionSlotType` 0 DOOR,1 HATCH,2 STRUCTURE,3 BALCONY,4 DECK ·
|
||||||
|
`ConnectionState` 0 DEFAULT,1 DOOR,2 OPEN · `ConnectionsCount` 0 FULL,1 PARTIAL,2 ERROR. Note the
|
||||||
|
master-server **WS form** serializes these as integers and omits null `EpbId`; the storage/hash form
|
||||||
|
uses name strings and includes `EpbId:null` — convert before hashing (`reverse/walkerdto_to_blueprint.py`).
|
||||||
|
|
||||||
|
## Tools (all scripts, with subcommands)
|
||||||
|
|
||||||
|
### `walker/` — `.wbt` save files (offline edit + hashes)
|
||||||
|
- **`sand.py`** — low-level toolkit. Subcommands: `decode <wbt> [-o]` · `snap (--all | files…)` ·
|
||||||
|
`diff <before> <after> [--no-filter]` · `check <wbt> [--no-filter]` · `watch <wbt> [--interval]`.
|
||||||
|
- **`build_wbt.py`** — high-level edit/build. Subcommands: `repack <wbt>` (identity sanity) ·
|
||||||
|
`rename <wbt> <first> <second> [-o]` (name indices 0–31) · `pack <wbt> -o out [--no-strict]`
|
||||||
|
(recompute hashes, write fresh) · `get-icon <wbt> [-o png]` · `set-icon <wbt> <png> [-o]`.
|
||||||
|
- **`harvest_hashes.py`** — scan saves+snapshots, merge `EpbId→{DefinitionHash,CompartmentHash}` into
|
||||||
|
the known-hashes table. Usage: `harvest_hashes.py [extra_dir …]`.
|
||||||
|
- **`recover_key.py`** — recover the XOR key from known-plaintext (the icon background pixel) after a
|
||||||
|
game update; no RE needed. Usage: `recover_key.py <wbt> …`.
|
||||||
|
- **`walker_hashes.py`** — reproduce `CompartmentsHash`/`ConnectionsHash` offline (the verified module).
|
||||||
|
|
||||||
|
### `bundle/` — Unity asset-bundle extraction (static data)
|
||||||
|
All use UnityPy with an IL2CPP TypeTreeGenerator (`GameAssembly.dll` + `global-metadata.dat`).
|
||||||
|
- **`unitybundle.py`** — minimal UnityFS extractor (LZ4/LZ4HC + uncompressed). `unitybundle.py [needle]`.
|
||||||
|
- **`odin_read.py`** — Sirenix **Odin** Binary (SerializedFormat=0) reader; used to decode
|
||||||
|
`SerializedBytes` blobs. `odin_read.py <file> [out]`.
|
||||||
|
- **`extract_data.py`** — generic MonoBehaviour extractor via typetrees → JSON in `extracted/`.
|
||||||
|
- **`extract_loot.py`** — loot/drop tables (Odin) → `extracted/loot_tables.json`.
|
||||||
|
- **`extract_production_lines.py`** — world conveyor single-recipe production lines → `extracted/production_lines.json`.
|
||||||
|
- **`extract_conveyor_placements.py`** — map islands→conveyors → `extracted/conveyor_placements.json`.
|
||||||
|
- **`extract_island_names.py`** — prefab→in-game Toponym (via `LandmarkBehaviour`) → `extracted/island_names.json`.
|
||||||
|
- **`extract_i2.py`** — I2 Localization English term table (manual parse) → i2 terms JSON.
|
||||||
|
- **`workbench_bundles.py`** — workbench EntityBlueprint → referenced `CraftingRecipeBundle`s.
|
||||||
|
- **`discord_recipes.py`** — emit Discord monospace recipe tables (workbench + production lines).
|
||||||
|
- **`component_census.py`** — tally ECS `$type` components across all 1446 EntityBlueprints. `component_census.py [filter]`.
|
||||||
|
- **`dump_blueprint.py`** — fully decode named EntityBlueprint(s): components + scalar fields. `dump_blueprint.py <base> …`.
|
||||||
|
- **`dump_loot_bytes.py`** / **`loot_probe.py`** — raw Odin byte dump / locate loot configs (analysis helpers).
|
||||||
|
|
||||||
|
### `reverse/` — network scraping + IL2CPP RE
|
||||||
|
- **`master_scrape.py`** — **the working master-server client** (2026-06-15 build). Two-socket
|
||||||
|
ClientMessage handshake: `/login` (no header) → `/connect` (`Authorization: <server ticket>`).
|
||||||
|
Flags: `--region {ger,eus,…}` `--go` (ARM network) `--data` `--user` `--client-version` `--insecure`
|
||||||
|
`--selftest`. Does nothing over the network without `--go`. See `docs/MASTER_SERVER.md` for the full
|
||||||
|
`ClientAction` enum / `OperationResult<T>` envelope.
|
||||||
|
- **`playfab_scrape.py`** — PlayFab REST (read-only), runs outside the game. Required `--title-id`;
|
||||||
|
auth via `--steam-ticket` or `--entity-token`; modes `--catalog` `--inventory` `--titledata`.
|
||||||
|
(Note: catalog/economy is disabled for this title — PlayFab is effectively auth-only.)
|
||||||
|
- **`capture_hosts.py`** — triage a pcap: DNS/SNI/endpoints, prints the PlayFab TitleId + master region. `capture_hosts.py <pcap>`.
|
||||||
|
- **`noise_filter.py`** — baseline-subtract a "SAND-off" pcap from a session pcap to isolate game traffic. `noise_filter.py <baseline> [session]`.
|
||||||
|
- **`ws_scrape.py`** — decode master-server WS frames from a pcap (older cleartext-era decoder; tries JSON/BSON/MessagePack). `ws_scrape.py <pcap> [--port --host --out]`.
|
||||||
|
- **`trampler_hashes.py`** — generate the blueprint hashes from scratch (Definition hash provisional). Self-test: run directly.
|
||||||
|
- **`walkerdto_to_blueprint.py`** — convert master-server `WalkerDto` (e.g. `GetExpedition.Trampler`) → loadable `WalkerBlueprintDto` + recompute hashes. Self-verifies via round-trip.
|
||||||
|
- **`render_trampler.py`** — render a multi-floor PNG map of a trampler (footprints, doors/hatches, guns) → `extracted/host_trampler_*.png`.
|
||||||
|
- **`il2cpp_re.py`** — IL2CPP helpers: VA↔file-offset, method index from `dump.cs`, xref finder, body disasm + float-constant extraction.
|
||||||
|
- **`resolve_decomp.py`** — annotate `ghidra/decomp.c` with symbol names + string literals. `resolve_decomp.py [substr]`.
|
||||||
|
- **`ghidra_decomp_targets.py`** / **`find_damage_writes.py`** — Ghidra headless decompile-target script / scan decomp for damage-write fingerprint.
|
||||||
|
|
||||||
|
### `wikigen/` — generate MediaWiki pages from `extracted/`
|
||||||
|
`make_items_wiki.py` · `make_crafting_wiki.py` · `make_loot_wiki.py` (→ `wiki/*.mediawiki`) ·
|
||||||
|
`render_wiki.py` (wikitext → standalone HTML in `wiki_site/`, git-ignored).
|
||||||
|
|
||||||
|
## Reference docs (`docs/`)
|
||||||
|
|
||||||
|
- **`MASTER_SERVER.md`** — master-server WebSocket protocol & scrape (transport, two-socket handshake, ClientAction enum, OperationResult).
|
||||||
|
- **`BACKEND_PLAYFAB.md`** — PlayFab is auth-only; read the corrections block at top.
|
||||||
|
- **`TRAMPLER.md`** — walker blueprint structure, the hashes, footprints, rendering.
|
||||||
|
- **`TASK.md`** — `.wbt` format cracked (BSON-verified) summary.
|
||||||
|
- **`PRODUCTION_LINES.md`**, **`SALES_VALUE.md`**, **`WEAPON_DAMAGE.md`** — static-data location maps (track across updates).
|
||||||
|
- **`SCRAPE_RUNBOOK.md`** — read-only live-scrape steps for when a playtest is online.
|
||||||
|
- **`BUNDLES.md`** (repo root) — inventory of the 35 asset bundles.
|
||||||
|
|
||||||
|
Operator memory lives in `~/.claude/projects/-home-downloadpizza-sand-tools/memory/` (loaded each session).
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Backend = PlayFab (Azure) — acquiring server-authoritative data
|
# 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)
|
## Observed boot behavior — no active playtest (GROUND TRUTH, reported by user)
|
||||||
|
|
||||||
When no playtest is running, the exact, observed sequence is:
|
When no playtest is running, the exact, observed sequence is:
|
||||||
|
|||||||
75
docs/MASTER_SERVER.md
Normal file
75
docs/MASTER_SERVER.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Master server (`*.hologryph.com`) — protocol & scrape (WORKING)
|
||||||
|
|
||||||
|
Reverse-engineered from a fresh `il2cpp/dump.cs` + Ghidra decompile of the **2026-06-15 build
|
||||||
|
(Steam buildid 23737037, GameAssembly.dll 143 MB)** and **verified live** — `reverse/master_scrape.py`
|
||||||
|
pulls the full economy + stats from an external process (no game, no BattlEye interaction).
|
||||||
|
|
||||||
|
> Supersedes earlier guesses: PlayFab is auth-only (see [BACKEND_PLAYFAB.md](BACKEND_PLAYFAB.md));
|
||||||
|
> the master server is **not** SignalR/gRPC and **not** cleartext `ws://:80`. The June-5 build used a
|
||||||
|
> per-call WebSocket-per-op model; **today's build changed to the two-socket `ClientMessage` protocol
|
||||||
|
> below** — re-RE was required after the playtest updated the client.
|
||||||
|
|
||||||
|
## Transport: `ClientMessage` over `wss://`, two sockets
|
||||||
|
|
||||||
|
Base region URL (live): `wss://ger.hologryph.com/gameclient/` (also eus/westus/sin/bra/aus/sko/uae;
|
||||||
|
client picks one via region selection). Everything is JSON **text** frames of:
|
||||||
|
|
||||||
|
```
|
||||||
|
ClientMessage = {"Id": <long>, "ClientMessageType": <int>, "Action": <int>, "Message": <string|null>}
|
||||||
|
ClientMessageType: Error=0, Ping=1, Action=2, Event=3 (requests use 2; Ping must be echoed)
|
||||||
|
Action (ClientAction enum): Login=0, Connect=1, GetCharacters=2, GetStorage=20, GetNews=21,
|
||||||
|
GetCompartmentDefinitions=23, GetDatabaseGuid=26, GetDefaultWalkerBlueprints=28, GetServers=29,
|
||||||
|
GetResearchTree=32, GetShopItems=39, ... (full list in il2cpp/dump.cs `enum ClientAction`)
|
||||||
|
Message: the request payload as a JSON *string* (nested), or null for parameterless ops.
|
||||||
|
```
|
||||||
|
Serialization = Newtonsoft default: **PascalCase fields, enums as integers**. Each request gets an
|
||||||
|
incrementing `Id`; the reply is a `ClientMessage` with the **same `Id`** (`_pendingRequests`
|
||||||
|
correlates them), so replies may interleave with `Event(3)` pushes and `Ping(1)`.
|
||||||
|
|
||||||
|
Reply `Message` is either the **DTO directly** (e.g. Login → `LoginUserResultDto`) or an
|
||||||
|
`OperationResult<T>` = `{IsSucceed,Error,Status,Result}` — `master_scrape.py` normalizes both.
|
||||||
|
|
||||||
|
## The handshake (this is the part that 1008s if wrong)
|
||||||
|
|
||||||
|
**Two sequential WebSocket connections:**
|
||||||
|
|
||||||
|
1. **`/login`** — connect `wss://<region>/gameclient/login` (NO auth header). Send
|
||||||
|
`ClientMessage{Action=Login(0), Message=JSON(LoginRequest{SessionTicket, Title, Platform, ClientVersion})}`:
|
||||||
|
- `SessionTicket` = the **PlayFab** SessionTicket (from `LoginWithSteam`; see playfab_scrape.py)
|
||||||
|
- `Title` = PlayFab TitleId = `56693`; `Platform` = `1` (STEAM, int); `ClientVersion` = the build's
|
||||||
|
**git commit hash** (e.g. `fbe5e4370daf86d54933ff4786b3312acd8c2d98`, from `Player.log`'s
|
||||||
|
`Send Login … ver:'…'`; changes every build — server may version-gate → `Status=5 VERSION_MISMATCH`).
|
||||||
|
- Reply `Message` = `LoginUserResultDto{User{Id,PlayFabId,PlatformId,DisplayName}, SessionTicket}`.
|
||||||
|
**Keep that `SessionTicket`** (the server session ticket). Close the socket.
|
||||||
|
2. **`/connect`** — connect `wss://<region>/gameclient/connect` **with HTTP upgrade header
|
||||||
|
`Authorization: <server SessionTicket>`** (raw, no `Bearer `). Send `ClientMessage{Action=Connect(1),
|
||||||
|
Message:"False"}` (the `isDebug` bool). This is the **persistent** socket; send all data ops over it.
|
||||||
|
|
||||||
|
Why the earlier attempts got `1008 "User is unauthorized!"`: connecting straight to `/connect` with no
|
||||||
|
`Authorization` header (the auth is the header, set from the ticket that only `/login` mints). There is
|
||||||
|
**no `SetRequestHeader` for `/login`** (its ticket rides inside the Login message instead).
|
||||||
|
|
||||||
|
## Data ops (over the `/connect` socket)
|
||||||
|
|
||||||
|
Send `ClientMessage{Action=<n>, Message:null}`; reply `Message` carries the result. Verified live:
|
||||||
|
|
||||||
|
| Action | n | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| GetCompartmentDefinitions | 23 | `List<CompartmentDefinitionDto>` — EpbId, HP, Weight, VisualWeight, **Properties[{Key,Value}]**, CrownPrice, T1/T2/T3_MetalPrice (126 items) |
|
||||||
|
| GetShopItems | 39 | `List<ShopItemDto>` — DefinitionName, Amount, BuyPrice[PriceDto] (54 items) |
|
||||||
|
| GetResearchTree | 32 | `ResearchTreeJsonDto{Roots[3], Nodes[98]}` |
|
||||||
|
| GetDefaultWalkerBlueprints | 28 | default walker blueprints |
|
||||||
|
| GetServers / GetNews / GetDatabaseGuid / GetCharacters / GetStorage | 29/21/26/2/20 | server list / news / db guid / your characters / wallet+inventory |
|
||||||
|
|
||||||
|
`PriceDto = {ItemDefinition (currency item id), Amount}`; names resolved via `extracted/item_names.json`.
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv/bin/python reverse/master_scrape.py --selftest # offline
|
||||||
|
venv/bin/python reverse/master_scrape.py # dry run (prints the exact frames)
|
||||||
|
venv/bin/python reverse/master_scrape.py --go --data # login + global data -> extracted/master_*.json
|
||||||
|
venv/bin/python reverse/master_scrape.py --go --user # + GetStorage / GetCharacters
|
||||||
|
```
|
||||||
|
Token from `reverse/.secrets/playfab_token.json` (gitignored): needs `SessionTicket` (fresh PlayFab
|
||||||
|
login) + `ClientVersion` (current build's commit hash). Output → `extracted/master_<Action>.json`.
|
||||||
67
docs/SCRAPE_RUNBOOK.md
Normal file
67
docs/SCRAPE_RUNBOOK.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Live-scrape runbook — when a playtest is online
|
||||||
|
|
||||||
|
Everything below is read-only and runs outside the game process (no BattlEye interaction).
|
||||||
|
Tooling is built and unit-tested; the only thing that needs a live backend is the data itself.
|
||||||
|
|
||||||
|
## 0. Capture (once servers are up)
|
||||||
|
|
||||||
|
1. `ipconfig /flushdns` (so hostnames show as clean DNS queries, incl. the PlayFab TitleId).
|
||||||
|
2. Start a packet capture on the game NIC (Wireshark, or `pktmon`/`dumpcap`). Save as `.pcapng`.
|
||||||
|
- Master-server traffic is **cleartext `ws://` on port 80** — Wireshark reads it directly,
|
||||||
|
**no MITM/cert needed**.
|
||||||
|
- PlayFab is HTTPS/443 — to read its bodies you need your MITM (cert already installed) on 443,
|
||||||
|
or use the REST scraper (step 3) instead.
|
||||||
|
3. Launch SAND, **click through past the "no servers"/welcome dialog and let it log in**, then open
|
||||||
|
the screens whose data you want (walker editor → compartment defs; research tree; store → prices).
|
||||||
|
Keep capturing through it. Stop the capture.
|
||||||
|
|
||||||
|
## 1. Triage the capture → get the TitleId + confirm the master server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv/bin/python reverse/capture_hosts.py <capture.pcapng>
|
||||||
|
```
|
||||||
|
Prints DNS/SNI/endpoints in order and a **BACKENDS DETECTED** block:
|
||||||
|
- `PlayFab host=<id>.playfabapi.com ** TitleId = <ID> **` ← the one constant the REST scraper needs
|
||||||
|
- `Master server host=<region>.hologryph.com (ws://80 cleartext)`
|
||||||
|
- `Config CDN host=sandconfigstorage…`
|
||||||
|
|
||||||
|
## 2. Master server (compartments + research tree + server list) — cleartext, no auth replay
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv/bin/python reverse/ws_scrape.py <capture.pcapng> --out extracted/master_ws.json
|
||||||
|
```
|
||||||
|
Reassembles the port-80 WebSocket to `*.hologryph.com/gameclient/`, parses RFC-6455 frames, and
|
||||||
|
decodes each message (tries JSON → BSON → MessagePack — the game's `IDataSerializer` is JSON-likely).
|
||||||
|
Messages are auto-tagged when their shape matches a known DTO:
|
||||||
|
`ServerDto`, `RegionInfo`, **`CompartmentDefinitionDto`** (HP/Weight/Properties/prices),
|
||||||
|
**`ResearchNodeJsonDto`** (connections via `RequiredNodesIds`/`DependentNodesIds`, costs via
|
||||||
|
`ResearchPrice`), `ItemDto`/`ShopItemDto`/`PriceDto`, `OperationResult`, `IClientEvent`.
|
||||||
|
If it finds no WS stream, the capture didn't span the master-server connection (re-capture through
|
||||||
|
the login), or try `--port`/`--host`.
|
||||||
|
|
||||||
|
> First run, eyeball one frame to confirm the encoding (JSON vs BSON). The decoder already handles
|
||||||
|
> both; this is just a sanity check.
|
||||||
|
|
||||||
|
## 3. PlayFab prices / catalog / inventory
|
||||||
|
|
||||||
|
Either read them from the MITM'd 443 capture, **or** pull them directly (cleaner, gets the *full*
|
||||||
|
catalog, more than the client requests):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# with a Steam auth ticket (captured, or minted via Steamworks GetAuthSessionTicket):
|
||||||
|
venv/bin/python reverse/playfab_scrape.py --title-id <ID> --steam-ticket <hex> --catalog --inventory
|
||||||
|
|
||||||
|
# or skip login with an EntityToken lifted from your MITM capture:
|
||||||
|
venv/bin/python reverse/playfab_scrape.py --title-id <ID> --entity-token <tok> --catalog
|
||||||
|
```
|
||||||
|
`--catalog` → `extracted/playfab_catalog.json`: every item with `PriceOptions` (→ currency-item +
|
||||||
|
amount, names resolved via `extracted/item_names.json`) and `DisplayProperties` (check here for any
|
||||||
|
catalog-authored base stats). `--inventory` → wallet + items + transaction history. `--titledata`
|
||||||
|
→ `Client/GetTitleData` config blobs. Read-only endpoints only — no write/purchase calls.
|
||||||
|
|
||||||
|
## Notes / unknowns to confirm live
|
||||||
|
- **WS payload encoding** (JSON vs BSON): decoder handles both; confirm on first capture.
|
||||||
|
- **Steam ticket reuse**: tickets are short-lived/single-use — if `--steam-ticket` fails, lift an
|
||||||
|
`EntityToken` from the MITM capture and use `--entity-token` instead.
|
||||||
|
- **Damage**: still server-computed; check `DisplayProperties` (catalog) and
|
||||||
|
`CompartmentDefinitionDto.Properties` (master server) for any base values — don't assume present.
|
||||||
146
docs/TRAMPLER.md
Normal file
146
docs/TRAMPLER.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Trampler / walker blueprints — structure, hashes, footprints, rendering
|
||||||
|
|
||||||
|
All findings from this session. Data-derived unless marked otherwise. Tooling in `reverse/`.
|
||||||
|
|
||||||
|
## Where blueprints come from
|
||||||
|
- **Your own trampler**: master-server `GetTrampler` (Action 16, `Message`=characterId) or PlayFab
|
||||||
|
`TitleData` (`TramplerBlueprint3`, a default). Shape = `WalkerBlueprintDto`.
|
||||||
|
- **Another crew member's / the host's**: `GetExpedition` (Action 7) → `ExpeditionDto.Trampler`
|
||||||
|
(a `WalkerDto`), or `GetExpeditionWalker(characterId)`. `WalkerDto` is the same build in a
|
||||||
|
slightly different wrapper (see conversion below).
|
||||||
|
- **Local saved walkers**: `.wbt` files in `…/Hologryph/Sand/Data/Walkers/*.wbt` (slightly different
|
||||||
|
serialization — parsed separately; not covered here).
|
||||||
|
|
||||||
|
## `WalkerBlueprintDto` (the blueprint)
|
||||||
|
```
|
||||||
|
Id, UniqueId, Version,
|
||||||
|
Chassis: CompartmentBlueprintDto,
|
||||||
|
Compartments: [CompartmentBlueprintDto], # ORDER IS MEANINGFUL = the game's order (matches Id field)
|
||||||
|
Connections: [ConnectionBlueprintDto],
|
||||||
|
CompartmentsHash, DefinitionsHash, ConnectionsHash # 3 MD5 hashes
|
||||||
|
```
|
||||||
|
- `CompartmentBlueprintDto = {Id, EpbId, CellCoordinate{x,y,z}, DecorationsInfo, Rotation,
|
||||||
|
CompartmentHash, DefinitionHash}` — `CellCoordinate` is the **origin/anchor cell**; `y` = floor.
|
||||||
|
- `ConnectionBlueprintDto = {Id, EpbId(null), ActualDirection{x,y,z}, GridCoordinate{x,y,z},
|
||||||
|
SlotType, State}`.
|
||||||
|
- Compartment **order = the game's order** (verified: cargo C1–C8 matched in-game). Same as the `Id`
|
||||||
|
field; arrays preserve it.
|
||||||
|
|
||||||
|
## `WalkerDto` → `WalkerBlueprintDto` conversion (`reverse/walkerdto_to_blueprint.py`)
|
||||||
|
`WalkerDto = {Id, FirstNameIndex, SecondNameIndex, BlueprintId, BlueprintUniqueId, Chassis,
|
||||||
|
Compartments:[CompartmentDto], Connections:[ConnectionDto]}` where `CompartmentDto={Id, Blueprint}`
|
||||||
|
and `ConnectionDto={Id, Blueprint}`.
|
||||||
|
- The WS form serializes **enums as ints and omits null EpbId**; the blueprint/hash ("storage") form
|
||||||
|
uses **enum NAME strings** and includes `EpbId:null`. Convert: unwrap `.Blueprint`, map enums
|
||||||
|
int→name, add `EpbId:null`. Enum maps (from dump.cs): `ConnectionSlotType`
|
||||||
|
DOOR0/HATCH1/STRUCTURE2/BALCONY3/DECK4; `ConnectionState` DEFAULT0/DOOR1/OPEN2;
|
||||||
|
`ConnectionsCount` FULL0/PARTIAL1/ERROR2.
|
||||||
|
- Verified by a **storage→WS→storage round-trip** on the sample (byte-identical, hashes reproduce).
|
||||||
|
|
||||||
|
## The 5 hashes (`reverse/trampler_hashes.py`, `walker/walker_hashes.py`)
|
||||||
|
`MD5Utility.ComputeHash(obj) = MD5( UTF8( JsonConvert.SerializeObject(obj) ) )` → **uppercase hex,
|
||||||
|
no separators** (`X2`). Newtonsoft compact: no whitespace, PascalCase, declaration-order keys, nulls
|
||||||
|
included, enums as **name strings**. A `WalkerBlueprintDto` carries **3 top-level** hashes; each
|
||||||
|
`CompartmentBlueprintDto` (chassis + every part) carries **2 more**.
|
||||||
|
|
||||||
|
| Hash | Scope | What it hashes | Offline-computable? |
|
||||||
|
|------|-------|----------------|---------------------|
|
||||||
|
| `CompartmentsHash` | top-level | `MD5(compact-JSON(Compartments list))` | **YES** — VERIFIED |
|
||||||
|
| `ConnectionsHash` | top-level | `MD5(compact-JSON(Connections list))` | **YES** — VERIFIED |
|
||||||
|
| `DefinitionsHash` | top-level | `MD5(JSON(Compartments→CompartmentDefinitionDto))` | **NO** — server-sourced |
|
||||||
|
| `CompartmentHash` | per-part | placement fingerprint (constant per EpbId+placement) | **NO from scratch** — harvest |
|
||||||
|
| `DefinitionHash` | per-part | `MD5(JSON(that part's CompartmentDefinitionDto))` | **NO** — server-sourced |
|
||||||
|
|
||||||
|
- The two **Definition** hashes (`DefinitionsHash` + per-part `DefinitionHash`) hash the rich
|
||||||
|
server-side `CompartmentDefinitionDto` — which is **NOT** in the blueprint and **NOT** equal to the
|
||||||
|
server's `GetCompartmentDefinitions` *pricing* DTO (brute-forcing every field subset of that DTO
|
||||||
|
reproduced neither). They can only be **harvested** (every part placed in the in-game editor writes
|
||||||
|
them into the `.wbt` save) → `extracted/definition_hashes_known.json`, `walker/harvest_hashes.py`.
|
||||||
|
- `CompartmentsHash` is a hash **over the Compartments list including each part's `CompartmentHash` +
|
||||||
|
`DefinitionHash` fields** — so wiping per-part Definition hashes also changes `CompartmentsHash`.
|
||||||
|
- Per-part `CompartmentHash` is **constant per EpbId** across placements (verified across 30 harvested
|
||||||
|
EpbIds); per-part `DefinitionHash` is constant per EpbId **except** it varies with `DecorationsInfo`
|
||||||
|
content (only `walker_compDeck_Square_Open_1x1_epb` seen to differ so far).
|
||||||
|
|
||||||
|
## Hash lifecycle & purpose — round-trip experiment (2026-06-16, live client)
|
||||||
|
Test: built copies of a known walker offline with hashes wiped, loaded them in the game, watched
|
||||||
|
`Player.log` (`CheckValidBlueprint: ERRORS {0}, VERSION:{1}, {2}` = `haveBuildingErrors`,
|
||||||
|
`isVersionValid`, `UniqueId`), then opened/saved in the in-game editor and re-decoded.
|
||||||
|
|
||||||
|
| Variant | Comp/Conn hashes | Definition hashes | `ERRORS` | `VERSION` |
|
||||||
|
|---------|------------------|-------------------|----------|-----------|
|
||||||
|
| Definition hashes only wiped | valid | wiped | False | **True** |
|
||||||
|
| **all 5 wiped** | wiped | wiped | False | **False** |
|
||||||
|
| untouched original (current build) | valid | valid | False | True |
|
||||||
|
| genuinely old-version walker | valid | valid | False | False |
|
||||||
|
|
||||||
|
Findings (data-derived):
|
||||||
|
- **The client recomputes ALL FIVE hashes locally on save** from its own `CompartmentsDatabase` +
|
||||||
|
`CompartmentDefinitionDto` container. Saving an all-wiped walker produced hashes **byte-identical** to
|
||||||
|
the original source (same build ⇒ same hashes). So the hashes carry **no per-walker secret** and are a
|
||||||
|
pure function of the build.
|
||||||
|
- **Open/edit tolerates blank hashes**: an all-wiped walker loads, lists, and opens in the editor with
|
||||||
|
no error; opening + closing **without saving rewrites nothing** (file stayed byte-identical). Hashes
|
||||||
|
are (re)written **only on save**.
|
||||||
|
- **Every save mints a new file UUID *and* a new `UniqueId`** (observed 3×).
|
||||||
|
- **`isVersionValid` (the `VERSION` flag) depends only on the structural hashes** (`CompartmentsHash`/
|
||||||
|
`ConnectionsHash`), **not** on the Definition hashes (def-only-wiped was still `VERSION:True`).
|
||||||
|
`haveBuildingErrors` (`ERRORS`) is a separate structural check, independent of every hash.
|
||||||
|
|
||||||
|
Purpose (mechanism from `dump.cs`, not inferred): `WalkerBlueprintContainer.ValidateVersion(
|
||||||
|
ICompartmentsDatabase, ICompartmentDefinitionDtoContainer)` sets `isVersionValid`/`haveBuildingErrors`.
|
||||||
|
It takes the **current** database + definitions as input ⇒ the stored hashes are a **cached fingerprint
|
||||||
|
re-checked against a fresh recompute**: when the game's part data changes across a patch, the recompute
|
||||||
|
won't match the stored hash and the walker is flagged stale (`VERSION:False`, → migrate/re-save). They
|
||||||
|
are **integrity / version-staleness markers, not security** — plain unsalted MD5 we reproduce offline;
|
||||||
|
the client just regenerates them. From the *client editor's* view the **Definition** hashes appear inert
|
||||||
|
(no effect on `ERRORS` or `VERSION`); whether the **server** validates them on upsert is still untested
|
||||||
|
(blocked by master-server `/connect` NREs during this playtest).
|
||||||
|
|
||||||
|
Practical upshot for offline editing: edit the `.wbt`, then either recompute the two structural hashes
|
||||||
|
yourself (`walker/walker_hashes.py`, exact) or just let **one in-editor save** regenerate all five.
|
||||||
|
|
||||||
|
## Footprints (`extracted/CompartmentsDatabase.json` → `compartments[].cells[]`)
|
||||||
|
Each compartment def has `cells[] = {position{x,y,z}, sockets{dir:{slotType:…}}, volumeOccupied,
|
||||||
|
requireSupport}` in the compartment's **local frame**. World cells = rotate local `(x,z)` by the
|
||||||
|
blueprint `Rotation`, add origin; `y` world = origin.y + local.y.
|
||||||
|
- **Rotation convention (verified by 0-overlap check across 56 solid cells):**
|
||||||
|
`90→(z,-x), 180→(-x,-z), 270→(-z,x)`.
|
||||||
|
- `volumeOccupied=true` = solid cell; `false` = non-solid (e.g. the reactor's north half-circle dome).
|
||||||
|
- Reactor `Round_Metal_2x1` is actually a 2-wide tower **rising y0→y5** with a 2×2 solid base + a
|
||||||
|
1-row dome to the north — the EpbId "2x1" is not the real footprint.
|
||||||
|
|
||||||
|
## Connections (doors / hatches) — blueprint `Connections[]`
|
||||||
|
Per slot: `GridCoordinate` (world cell), `ActualDirection` (face), `SlotType`, `State`.
|
||||||
|
- **DOOR** (horizontal, `dir.y==0`): `State` DOOR/OPEN = real door, DEFAULT = wall.
|
||||||
|
- **HATCH** (vertical, `dir.y==±1`): `State` DOOR = hatch between floors.
|
||||||
|
- STRUCTURE / DECK / BALCONY = structural/walkable connections.
|
||||||
|
|
||||||
|
## Weapons
|
||||||
|
- `BattleRam_2x2` = **rams** (no firing arc — battering rams). `TurretSlot_*` = turret mounts;
|
||||||
|
`…OpenCorner…`/`…MetalCorner…` = **45° corner** mounts, others = straight/cardinal.
|
||||||
|
- **Firing facing is NOT in the data** (weapon `Properties` empty; `CompartmentsDatabase` has no
|
||||||
|
aim/forward/angle field). The renderer derives facing from the **`Rotation` field** + a
|
||||||
|
user-supplied calibration (CALIBRATION, not pure data): straight rot0 = South, corner rot0 = SW,
|
||||||
|
+90° = clockwise → `face = base − rotation` (base: straight 90°, corner 135°), arc = ±90°.
|
||||||
|
|
||||||
|
## Renderer (`reverse/render_trampler.py`)
|
||||||
|
Per-floor (y −1…3) PNG: real footprints (solid vs faded non-solid), doors/hatches, single-tile guns
|
||||||
|
with ±90° firing wedges, rams, cargo numbered **C1–C8** in JSON order. Outputs
|
||||||
|
`extracted/host_trampler_*.png`. Reactor column above deck 3 and legs below the base are clipped.
|
||||||
|
|
||||||
|
## Other scrape tooling (this session)
|
||||||
|
- `reverse/master_scrape.py` — master-server replay (two-socket `/login` + `/connect`; see
|
||||||
|
[MASTER_SERVER.md](MASTER_SERVER.md)).
|
||||||
|
- `reverse/{capture_hosts,ws_scrape,playfab_scrape,noise_filter,resolve_decomp}.py`.
|
||||||
|
- Ghidra: `analyzeHeadless … -postScript decomp_targets.py|disasm_targets.py` (offset bug fixed:
|
||||||
|
rva = il2cppdumper `Address` directly, **no −0x1000**).
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
- **Server-side upsert validation — UNTESTED.** We've only confirmed the *client* tolerates wiped/blank
|
||||||
|
hashes (loads, opens, edits, and regenerates all 5 on save). Whether the **master server** validates
|
||||||
|
the hashes (esp. the server-sourced `DefinitionsHash` / per-part `DefinitionHash`) when a blueprint is
|
||||||
|
`UpsertTrampler`'d — i.e. whether it recomputes, rejects, or stores blanks — is not yet known. It was
|
||||||
|
blocked this playtest by the master server throwing `System.NullReferenceException` and closing the
|
||||||
|
socket on `/connect` (no successful login). Re-test once `/connect` is healthy: upsert a walker with
|
||||||
|
Definition hashes blank, then `GetTrampler` it back and diff. See [MASTER_SERVER.md](MASTER_SERVER.md).
|
||||||
17187
extracted/host_trampler_blueprint.json
Normal file
17187
extracted/host_trampler_blueprint.json
Normal file
File diff suppressed because it is too large
Load Diff
66
extracted/host_trampler_map.txt
Normal file
66
extracted/host_trampler_map.txt
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
TRAMPLER MAP (host crew walker, 70 parts) chassis=walker_compChassis_Long8_Metal_4x6_epb
|
||||||
|
axes: X=columns (-2..3), Z=rows (-3..4). origin-cell per compartment.
|
||||||
|
|
||||||
|
==== deck 3 (y=3) (2 compartments) ====
|
||||||
|
x:-2-1 0 1 2 3
|
||||||
|
z -3 . . . . . .
|
||||||
|
z -2 . . . . . .
|
||||||
|
z -1 . . . . . .
|
||||||
|
z 0 . . . . . .
|
||||||
|
z 1 . . . . . .
|
||||||
|
z 2 . . . . . .
|
||||||
|
z 3 . . W W . .
|
||||||
|
z 4 . . . . . .
|
||||||
|
|
||||||
|
==== deck 2 (y=2) (15 compartments) ====
|
||||||
|
x:-2-1 0 1 2 3
|
||||||
|
z -3 . . . . . .
|
||||||
|
z -2 . W D D W .
|
||||||
|
z -1 . D . . D .
|
||||||
|
z 0 . D . . D .
|
||||||
|
z 1 . . . . D .
|
||||||
|
z 2 . W D D W .
|
||||||
|
z 3 . . W W . .
|
||||||
|
z 4 . . . . . .
|
||||||
|
|
||||||
|
==== deck 1 (y=1) (24 compartments) ====
|
||||||
|
x:-2-1 0 1 2 3
|
||||||
|
z -3 . . . . . .
|
||||||
|
z -2 . C D D C .
|
||||||
|
z -1 . C . . D .
|
||||||
|
z 0 . C . . C .
|
||||||
|
z 1 A F D C c .
|
||||||
|
z 2 A + + + + A
|
||||||
|
z 3 . W D D W .
|
||||||
|
z 4 . . . T . .
|
||||||
|
|
||||||
|
==== deck 0 (y=0) (28 compartments) ====
|
||||||
|
x:-2-1 0 1 2 3
|
||||||
|
z -3 . . . S . .
|
||||||
|
z -2 . . c c . .
|
||||||
|
z -1 . K . . + A
|
||||||
|
z 0 . C R . C A
|
||||||
|
z 1 A + . . + A
|
||||||
|
z 2 A + E E + A
|
||||||
|
z 3 A + + + + A
|
||||||
|
z 4 . W . W . .
|
||||||
|
|
||||||
|
==== hull/chassis (y=-1) (1 compartments) ====
|
||||||
|
x:-2-1 0 1 2 3
|
||||||
|
z -3 . . . . . .
|
||||||
|
z -2 . . . . . .
|
||||||
|
z -1 . . . . . .
|
||||||
|
z 0 . . # . . .
|
||||||
|
z 1 . . . . . .
|
||||||
|
z 2 . . . . . .
|
||||||
|
z 3 . . . . . .
|
||||||
|
z 4 . . . . . .
|
||||||
|
|
||||||
|
LEGEND: #=Chassis R=Reactor E=Engine W=Weapon A=Armor C=Cargo c=Crew K=CaptainCrew D=Deck +=Corridor S=Special F=Crafting T=Steering
|
||||||
|
|
||||||
|
PER-FLOOR TYPES:
|
||||||
|
deck 3 (y=3) {'Weapon': 2}
|
||||||
|
deck 2 (y=2) {'Deck': 9, 'Weapon': 6}
|
||||||
|
deck 1 (y=1) {'Cargo': 6, 'Deck': 6, 'Corridor': 4, 'Armor': 3, 'Weapon': 2, 'Crew': 1, 'Crafting': 1, 'Steering': 1}
|
||||||
|
deck 0 (y=0) {'Corridor': 9, 'Armor': 8, 'Engine': 2, 'Crew': 2, 'Cargo': 2, 'Weapon': 2, 'Reactor': 1, 'CaptainCrew': 1, 'Special': 1}
|
||||||
|
hull/chassis (y=-1) {'Chassis': 1}
|
||||||
BIN
extracted/host_trampler_nice.png
Normal file
BIN
extracted/host_trampler_nice.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
68
extracted/master_GetCharacters.json
Normal file
68
extracted/master_GetCharacters.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"_op": "GetCharacters",
|
||||||
|
"Result": [
|
||||||
|
{
|
||||||
|
"Character": {
|
||||||
|
"Id": 1165,
|
||||||
|
"UserId": 1132,
|
||||||
|
"NameIndex": 20,
|
||||||
|
"SurnameIndex": 12,
|
||||||
|
"PlatformId": {
|
||||||
|
"Platform": "Steam",
|
||||||
|
"Value": "76561198355752256"
|
||||||
|
},
|
||||||
|
"PlayFabId": "42E47F978A8574CD",
|
||||||
|
"Customizations": [
|
||||||
|
{
|
||||||
|
"Name": "head_m_01_pirate",
|
||||||
|
"ViewData": "cloth_head_m_01_pirate_prefab",
|
||||||
|
"BodyPart": "HEAD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "hair_03_chub",
|
||||||
|
"ViewData": "cloth_hair_03_chub_prefab",
|
||||||
|
"BodyPart": "HAIR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "face_01_mustache",
|
||||||
|
"ViewData": "cloth_face_01_mustache_prefab",
|
||||||
|
"BodyPart": "FACE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "default_torso_shirt_v_Black",
|
||||||
|
"ViewData": "cloth_default_torso_shirt_prefab_v_Black",
|
||||||
|
"BodyPart": "TORSO"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "EMPTY_HANDS",
|
||||||
|
"ViewData": null,
|
||||||
|
"BodyPart": "HANDS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "default_legs_pants",
|
||||||
|
"ViewData": "cloth_default_legs_pants_prefab",
|
||||||
|
"BodyPart": "LEGS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "farmer_feet_boots",
|
||||||
|
"ViewData": "cloth_farmer_feet_boots_prefab",
|
||||||
|
"BodyPart": "FEET"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "hat_01_black",
|
||||||
|
"ViewData": "cloth_hat_01_prefab_black",
|
||||||
|
"BodyPart": "HAT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsSelected": false,
|
||||||
|
"DeletionTime": null
|
||||||
|
},
|
||||||
|
"Resources": {
|
||||||
|
"Crowns": 1606,
|
||||||
|
"MechanicalParts": 3580,
|
||||||
|
"PneumaticParts": 522,
|
||||||
|
"ComputingModules": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2278
extracted/master_GetCompartmentDefinitions.json
Normal file
2278
extracted/master_GetCompartmentDefinitions.json
Normal file
File diff suppressed because it is too large
Load Diff
4
extracted/master_GetDatabaseGuid.json
Normal file
4
extracted/master_GetDatabaseGuid.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"_op": "GetDatabaseGuid",
|
||||||
|
"Result": "d54b40ce6f73fd7cfd7d1f7ffeb2eadaf9520433318e7e5c01ee6d7759731690"
|
||||||
|
}
|
||||||
12762
extracted/master_GetDefaultWalkerBlueprints.json
Normal file
12762
extracted/master_GetDefaultWalkerBlueprints.json
Normal file
File diff suppressed because it is too large
Load Diff
19579
extracted/master_GetExpedition.json
Normal file
19579
extracted/master_GetExpedition.json
Normal file
File diff suppressed because it is too large
Load Diff
4
extracted/master_GetNews.json
Normal file
4
extracted/master_GetNews.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"_op": "GetNews",
|
||||||
|
"Result": []
|
||||||
|
}
|
||||||
3759
extracted/master_GetResearchTree.json
Normal file
3759
extracted/master_GetResearchTree.json
Normal file
File diff suppressed because it is too large
Load Diff
761
extracted/master_GetShopItems.json
Normal file
761
extracted/master_GetShopItems.json
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
{
|
||||||
|
"_op": "GetShopItems",
|
||||||
|
"Result": [
|
||||||
|
{
|
||||||
|
"Id": "revolverSmall_coinCrown",
|
||||||
|
"DefinitionName": "item_revolverSmall",
|
||||||
|
"_name": "EB \"Zseb\" Revolver",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "pistolAmmo_coinCrown",
|
||||||
|
"DefinitionName": "item_pistolAmmo",
|
||||||
|
"_name": "8x21 mm Ammo",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "foodCan_coinCrown",
|
||||||
|
"DefinitionName": "item_foodCan",
|
||||||
|
"_name": "Canned Food",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 30
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedTurretT1Container_coinCrown",
|
||||||
|
"DefinitionName": "game_packedTurretT1Container",
|
||||||
|
"_name": "Rusty 80 mm Naval Cannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "turretAmmo_coinCrown",
|
||||||
|
"DefinitionName": "item_turretAmmo",
|
||||||
|
"_name": "80 mm Shell",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceMetal_t1_coinCrown",
|
||||||
|
"DefinitionName": "item_resourceMetal_t1",
|
||||||
|
"_name": "Mechanical Parts",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "energyBar_coinCrown",
|
||||||
|
"DefinitionName": "item_energyBar",
|
||||||
|
"_name": "NZ Mk2 Energy Rod",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 125
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "MedKit_resourceMetalParts",
|
||||||
|
"DefinitionName": "MedKit",
|
||||||
|
"_name": "MedKit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceMetalParts",
|
||||||
|
"_name": "Scrap Metal",
|
||||||
|
"Amount": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "treasureShovel_resourceCoralPiece",
|
||||||
|
"DefinitionName": "item_treasureShovel",
|
||||||
|
"_name": "Rusty Shovel",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceCoralPiece",
|
||||||
|
"_name": "Coral Chunk",
|
||||||
|
"Amount": 30
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "smokeGrenade_resourceReinforcedLeatherStrips",
|
||||||
|
"DefinitionName": "item_smokeGrenade",
|
||||||
|
"_name": "RG79s Smoke Grenade",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceReinforcedLeatherStrips",
|
||||||
|
"_name": "Reinforced Leather Strips",
|
||||||
|
"Amount": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceScrappedAmmo_coinCrown",
|
||||||
|
"DefinitionName": "item_resourceScrappedAmmo",
|
||||||
|
"_name": "Scrapped Ammo",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceMetalParts_coinCrown",
|
||||||
|
"DefinitionName": "item_resourceMetalParts",
|
||||||
|
"_name": "Scrap Metal",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceMixtures_coinCrown",
|
||||||
|
"DefinitionName": "item_resourceMixtures",
|
||||||
|
"_name": "Mixtures",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceCoralPiece_coinCrown",
|
||||||
|
"DefinitionName": "item_resourceCoralPiece",
|
||||||
|
"_name": "Coral Chunk",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceFabricScraps_coinCrown",
|
||||||
|
"DefinitionName": "item_resourceFabricScraps",
|
||||||
|
"_name": "Fabric Scraps",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceThreads_coinCrown",
|
||||||
|
"DefinitionName": "item_resourceThreads",
|
||||||
|
"_name": "Threads",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "smokelessEnergyBar_crystalHandles",
|
||||||
|
"DefinitionName": "item_smokelessEnergyBar",
|
||||||
|
"_name": "NZ Mk2-RF Smokeless Energy Rod",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_crystalHandles",
|
||||||
|
"_name": "Raw Aurogen Crystal",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedAutoTurretT1Container_coinCrown",
|
||||||
|
"DefinitionName": "game_packedAutoTurretT1Container",
|
||||||
|
"_name": "Rusty 40 mm Autocannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedShotgunTurretT1Container_coinCrown",
|
||||||
|
"DefinitionName": "game_packedShotgunTurretT1Container",
|
||||||
|
"_name": "Rusty 70 mm Shotgun Cannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "smallCannonAmmo_coinCrown",
|
||||||
|
"DefinitionName": "item_smallCannonAmmo",
|
||||||
|
"_name": "40 mm Shell",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "shotgunTurretAmmo_coinCrown",
|
||||||
|
"DefinitionName": "item_shotgunTurretAmmo",
|
||||||
|
"_name": "70 mm Shell",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedTurretT2Container_wineBox",
|
||||||
|
"DefinitionName": "game_packedTurretT2Container",
|
||||||
|
"_name": "Worn 80 mm Naval Cannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_wineBox",
|
||||||
|
"_name": "Crate of 1889 Chardonnay",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedAutoTurretT2Container_documentSafe",
|
||||||
|
"DefinitionName": "game_packedAutoTurretT2Container",
|
||||||
|
"_name": "Worn 40 mm Autocannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_documentSafe",
|
||||||
|
"_name": "District Officer's Portable Safe",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedShotgunTurretT2Container_cannedFish",
|
||||||
|
"DefinitionName": "game_packedShotgunTurretT2Container",
|
||||||
|
"_name": "Worn 70 mm Shotgun Cannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_cannedFish",
|
||||||
|
"_name": "Canned Sea Deer XL",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceMetal_t2_resourceWeaponParts",
|
||||||
|
"DefinitionName": "item_resourceMetal_t2",
|
||||||
|
"_name": "Pneumatic Parts",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceWeaponParts",
|
||||||
|
"_name": "Weapon Parts",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceMetal_t3_resourceHighGradeGunpowder",
|
||||||
|
"DefinitionName": "item_resourceMetal_t3",
|
||||||
|
"_name": "Computing Module",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceHighGradeGunpowder",
|
||||||
|
"_name": "High-Grade Gunpowder",
|
||||||
|
"Amount": 15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedTurretT3Container_wineBox",
|
||||||
|
"DefinitionName": "game_packedTurretT3Container",
|
||||||
|
"_name": "Pristine 80 mm Naval Cannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_wineBox",
|
||||||
|
"_name": "Crate of 1889 Chardonnay",
|
||||||
|
"Amount": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedAutoTurretT3Container_documentSafe",
|
||||||
|
"DefinitionName": "game_packedAutoTurretT3Container",
|
||||||
|
"_name": "Pristine 40 mm Autocannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_documentSafe",
|
||||||
|
"_name": "District Officer's Portable Safe",
|
||||||
|
"Amount": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedShotgunTurretT3Container_cannedFish",
|
||||||
|
"DefinitionName": "game_packedShotgunTurretT3Container",
|
||||||
|
"_name": "Pristine 70 mm Shotgun Cannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_cannedFish",
|
||||||
|
"_name": "Canned Sea Deer XL",
|
||||||
|
"Amount": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "resourceMetal_t2_coinCrown",
|
||||||
|
"DefinitionName": "item_resourceMetal_t2",
|
||||||
|
"_name": "Pneumatic Parts",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedTurretT2Container_coinCrown",
|
||||||
|
"DefinitionName": "game_packedTurretT2Container",
|
||||||
|
"_name": "Worn 80 mm Naval Cannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 2000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedAutoTurretT2Container_coinCrown",
|
||||||
|
"DefinitionName": "game_packedAutoTurretT2Container",
|
||||||
|
"_name": "Worn 40 mm Autocannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 2000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "packedShotgunTurretT2Container_coinCrown",
|
||||||
|
"DefinitionName": "game_packedShotgunTurretT2Container",
|
||||||
|
"_name": "Worn 70 mm Shotgun Cannon Kit",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 2000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "rifleMusket_coinCrown",
|
||||||
|
"DefinitionName": "item_rifleMusket",
|
||||||
|
"_name": "M1866/9 \"Einzel\" Breechloader",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "shotgunHandmade_coinCrown",
|
||||||
|
"DefinitionName": "item_shotgunHandmade",
|
||||||
|
"_name": "Drobulet Shotgun",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "semiAutomaticPistol_decreasedMag_coinCrown",
|
||||||
|
"DefinitionName": "item_semiAutomaticPistol_decreasedMag",
|
||||||
|
"_name": "Blitz PPS-5 Pistol",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "rifleAmmo_coinCrown",
|
||||||
|
"DefinitionName": "item_rifleAmmo",
|
||||||
|
"_name": "9x42 mm Ammo",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "shotgunAmmo_coinCrown",
|
||||||
|
"DefinitionName": "item_shotgunAmmo",
|
||||||
|
"_name": "12 GA Ammo",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "Old_Jacket_resourceScrappedAmmo",
|
||||||
|
"DefinitionName": "Old_Jacket",
|
||||||
|
"_name": "Old Smuggler's Jacket",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceScrappedAmmo",
|
||||||
|
"_name": "Scrapped Ammo",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "semiAutomaticPistol_resourceMixtures",
|
||||||
|
"DefinitionName": "item_semiAutomaticPistol",
|
||||||
|
"_name": "Blitz 10R Pistol",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceMixtures",
|
||||||
|
"_name": "Mixtures",
|
||||||
|
"Amount": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "repeaterRifle_resourceLeviathanSkin",
|
||||||
|
"DefinitionName": "item_repeaterRifle",
|
||||||
|
"_name": "M82 Rifle",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceLeviathanSkin",
|
||||||
|
"_name": "Leviathan Skin",
|
||||||
|
"Amount": 35
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "shotgun_resourceMetalRods",
|
||||||
|
"DefinitionName": "item_shotgun",
|
||||||
|
"_name": "Pepper Mill Shotgun",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceMetalRods",
|
||||||
|
"_name": "Metal Rods",
|
||||||
|
"Amount": 35
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "grenadeContact_resourceOpticLenses",
|
||||||
|
"DefinitionName": "item_grenadeContact",
|
||||||
|
"_name": "HG-6 Contact Grenade",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceOpticLenses",
|
||||||
|
"_name": "Optic Lenses",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "c4Dynamite_resourceWeaponParts",
|
||||||
|
"DefinitionName": "item_c4Dynamite",
|
||||||
|
"_name": "Time Bomb",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceWeaponParts",
|
||||||
|
"_name": "Weapon Parts",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "Old_Jacket_coinCrown",
|
||||||
|
"DefinitionName": "Old_Jacket",
|
||||||
|
"_name": "Old Smuggler's Jacket",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 150
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "Old_JacketT2_resourceHighGradeGunpowder",
|
||||||
|
"DefinitionName": "Old_JacketT2",
|
||||||
|
"_name": "SGOW SW.52 Body Armor (decommissioned)",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceHighGradeGunpowder",
|
||||||
|
"_name": "High-Grade Gunpowder",
|
||||||
|
"Amount": 15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "sniperRifleAmmo_resourceFabricScraps",
|
||||||
|
"DefinitionName": "item_sniperRifleAmmo",
|
||||||
|
"_name": "11x54 mm Ammo",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceFabricScraps",
|
||||||
|
"_name": "Fabric Scraps",
|
||||||
|
"Amount": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "sniperRifle_ironSights_resourceCoralDust",
|
||||||
|
"DefinitionName": "item_sniperRifle_ironSights",
|
||||||
|
"_name": "1874e Petros Rifle",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceCoralDust",
|
||||||
|
"_name": "Coral Dust",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "shotgun_coinCrown",
|
||||||
|
"DefinitionName": "item_shotgun",
|
||||||
|
"_name": "Pepper Mill Shotgun",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 200
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "repeaterRifle_coinCrown",
|
||||||
|
"DefinitionName": "item_repeaterRifle",
|
||||||
|
"_name": "M82 Rifle",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 200
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "semiAutomaticPistol_coinCrown",
|
||||||
|
"DefinitionName": "item_semiAutomaticPistol",
|
||||||
|
"_name": "Blitz 10R Pistol",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"_name": "Crowns",
|
||||||
|
"Amount": 200
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "pistolAmmo_highVelocity_resourceFabric",
|
||||||
|
"DefinitionName": "item_pistolAmmo_highVelocity",
|
||||||
|
"_name": "8x21 mm HV Ammo",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceFabric",
|
||||||
|
"_name": "Fabric",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "rifleAmmo_highVelocity_resourceLeviathanMeat",
|
||||||
|
"DefinitionName": "item_rifleAmmo_highVelocity",
|
||||||
|
"_name": "9x42 mm HV Ammo",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceLeviathanMeat",
|
||||||
|
"_name": "Leviathan Meat",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "shotgunAmmo_slug_resourceLeviathanSkin",
|
||||||
|
"DefinitionName": "item_shotgunAmmo_slug",
|
||||||
|
"_name": "12 GA Shotgun Slug",
|
||||||
|
"Amount": 1,
|
||||||
|
"BuyPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_resourceLeviathanSkin",
|
||||||
|
"_name": "Leviathan Skin",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsLocked": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
878
extracted/master_GetStorage.json
Normal file
878
extracted/master_GetStorage.json
Normal file
@@ -0,0 +1,878 @@
|
|||||||
|
{
|
||||||
|
"Id": 1165,
|
||||||
|
"Crowns": 2311,
|
||||||
|
"MechanicalParts": 3580,
|
||||||
|
"PneumaticParts": 522,
|
||||||
|
"ComputingModules": 1,
|
||||||
|
"SlotCount": 70,
|
||||||
|
"Items": [
|
||||||
|
{
|
||||||
|
"Id": "256002-f515fbc5-16ca-476d-bc77-5053c691523f",
|
||||||
|
"DefinitionName": "item_resourceMetal_t3",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "256003-15f78a10-1cf7-4002-819c-177474ad07f5",
|
||||||
|
"DefinitionName": "item_weirdCoral",
|
||||||
|
"Amount": 4,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "256004-beb2ff9f-1bd0-4534-9023-a8bab62c2682",
|
||||||
|
"DefinitionName": "item_resourceCoralPiece",
|
||||||
|
"Amount": 81,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "256007-67594b0d-275d-49ad-80cb-85aba155d308",
|
||||||
|
"DefinitionName": "item_resourceOpticLenses",
|
||||||
|
"Amount": 7,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "256012-fef66cdf-fe5e-4268-9f59-385225bd6d88",
|
||||||
|
"DefinitionName": "item_semiAutomaticPistol",
|
||||||
|
"Amount": 2,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "256013-3505b19b-af14-44ad-8e0c-bd40f73bce73",
|
||||||
|
"DefinitionName": "item_pistolAmmo_Armor",
|
||||||
|
"Amount": 40,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "256015-01fb15d8-990d-4012-9499-fdc5b724e550",
|
||||||
|
"DefinitionName": "item_resourceHighGradeGunpowder",
|
||||||
|
"Amount": 7,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "256016-534c1e59-8258-4579-83bc-5124c2c47125",
|
||||||
|
"DefinitionName": "MedKit",
|
||||||
|
"Amount": 9,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26798-129b1947-ae65-4165-841f-53a224d2198e",
|
||||||
|
"DefinitionName": "item_resourceMetal_t1",
|
||||||
|
"Amount": 3580,
|
||||||
|
"SellPrice": [],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26800-1a71b2b7-bcd5-4f69-8555-22e4ca6e9074",
|
||||||
|
"DefinitionName": "item_semiAutomaticPistol_decreasedMag",
|
||||||
|
"Amount": 6,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26803-5f73b630-82c0-43f3-8545-70c04fadd5dc",
|
||||||
|
"DefinitionName": "item_pistolAmmo",
|
||||||
|
"Amount": 778,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26804-3d2b7a0c-5505-4a42-8645-cb2ac4c2d6f0",
|
||||||
|
"DefinitionName": "item_shotgunAmmo",
|
||||||
|
"Amount": 164,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26805-e341d3ab-8a82-41ac-8d52-b3e48c3d53c9",
|
||||||
|
"DefinitionName": "item_rifleAmmo",
|
||||||
|
"Amount": 121,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26806-0b0e4d14-885d-4a84-b075-a792e06b5cac",
|
||||||
|
"DefinitionName": "item_foodCan",
|
||||||
|
"Amount": 54,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26808-559e9bf8-8daf-4b0c-bb7d-2a4d01186455",
|
||||||
|
"DefinitionName": "item_turretAmmo",
|
||||||
|
"Amount": 146,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26809-006e8333-7bc8-4e86-b1a9-290c54aac10b",
|
||||||
|
"DefinitionName": "item_shotgunTurretAmmo",
|
||||||
|
"Amount": 200,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26809-180ef948-ab1e-4cd8-82e3-1d378df693ee",
|
||||||
|
"DefinitionName": "item_shotgunTurretAmmo",
|
||||||
|
"Amount": 16,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26810-6fe4bc24-27da-4841-a09b-de84ac99e08a",
|
||||||
|
"DefinitionName": "item_smallCannonAmmo",
|
||||||
|
"Amount": 500,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26810-1f17244f-6efe-4499-8345-75ac1fa94661",
|
||||||
|
"DefinitionName": "item_smallCannonAmmo",
|
||||||
|
"Amount": 37,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26811-03dfdaaa-86f8-4a99-9218-7cde70d5c2e4",
|
||||||
|
"DefinitionName": "game_packedTurretT1Container",
|
||||||
|
"Amount": 4,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": true,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26812-659e4fa2-f22c-4ae4-9263-78bdb6fb920e",
|
||||||
|
"DefinitionName": "game_packedAutoTurretT1Container",
|
||||||
|
"Amount": 4,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": true,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "26813-55f213e3-25d5-4319-9051-c66bed209d62",
|
||||||
|
"DefinitionName": "game_packedShotgunTurretT1Container",
|
||||||
|
"Amount": 4,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": true,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557071-f64caa35-7bba-407c-8700-46b4529d1cdf",
|
||||||
|
"DefinitionName": "item_antiReactorGun",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 300
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557077-974feaae-af31-4d76-9ddd-b031affec7a5",
|
||||||
|
"DefinitionName": "item_revolverSmall",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557078-6a184cb3-4151-4cd0-b04a-ffc935bdc56c",
|
||||||
|
"DefinitionName": "game_packedShotgunTurretT2Container",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 200
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": true,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557079-99d1079f-000d-46c6-a811-f9c2fcc30375",
|
||||||
|
"DefinitionName": "Old_Jacket",
|
||||||
|
"Amount": 2,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 30
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557080-3d483659-4c5a-4ea7-9f71-f4744aac9683",
|
||||||
|
"DefinitionName": "item_revolverQuickReload",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557081-ef10db4c-f684-4402-b4bb-7f1f58a3bb02",
|
||||||
|
"DefinitionName": "item_smallCannonAmmo_lowRecoil",
|
||||||
|
"Amount": 4,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557082-3ff377ac-73fc-4544-9f2b-4bcbc6812443",
|
||||||
|
"DefinitionName": "item_smallCannonAmmo_longRange",
|
||||||
|
"Amount": 15,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557083-69d8d72e-e507-440a-a3a6-ae8e9f88ea3b",
|
||||||
|
"DefinitionName": "item_shotgunTurretAmmo_slug",
|
||||||
|
"Amount": 3,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 30
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557084-10580fb4-0b75-447f-8b33-62837c697700",
|
||||||
|
"DefinitionName": "item_turretAmmo_delayedDetonation",
|
||||||
|
"Amount": 3,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 30
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557085-d3a05524-412a-4b26-8a56-c1ae95dc22b1",
|
||||||
|
"DefinitionName": "item_rifleAmmo_Armor",
|
||||||
|
"Amount": 11,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557086-3294205c-a149-4181-b074-5ecfa158665f",
|
||||||
|
"DefinitionName": "item_c4Dynamite",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557087-6f58d246-3747-4d78-816a-28e47aedbbd5",
|
||||||
|
"DefinitionName": "item_rifleMusket",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557088-cbc87cb4-0a8f-4f10-af4f-0c33fc4e8089",
|
||||||
|
"DefinitionName": "item_sniperRifle_ironSights",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557089-5174aa9a-b8fb-47b6-afbc-452b4584feda",
|
||||||
|
"DefinitionName": "item_smallValuables",
|
||||||
|
"Amount": 77,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557090-1309a2d8-3ebc-45f5-b5e6-f26538a4d0ab",
|
||||||
|
"DefinitionName": "item_resourceLeviathanSkin",
|
||||||
|
"Amount": 3,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2557091-f32adf2c-49a5-4a86-ac87-2ee1cf6f56a1",
|
||||||
|
"DefinitionName": "item_blackBox",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": false,
|
||||||
|
"IsLarge": true,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "52307-2b0ddd39-6778-442e-ab99-24f093780eeb",
|
||||||
|
"DefinitionName": "item_energyBar",
|
||||||
|
"Amount": 20,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "52307-4015dc0b-9e54-4158-8a63-aa11a738f13f",
|
||||||
|
"DefinitionName": "item_energyBar",
|
||||||
|
"Amount": 2,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "52310-43008556-1759-4a27-b285-139d5d5865ff",
|
||||||
|
"DefinitionName": "item_resourceFabricScraps",
|
||||||
|
"Amount": 506,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "52311-08291d63-53f2-47d5-afef-79c94cbfaafb",
|
||||||
|
"DefinitionName": "item_resourceMetalRods",
|
||||||
|
"Amount": 23,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "52314-56d8e0e0-e180-4fe4-af1c-1683f2686dd4",
|
||||||
|
"DefinitionName": "item_coinCrown",
|
||||||
|
"Amount": 2311,
|
||||||
|
"SellPrice": [],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "52315-7340912f-8e70-48cb-9fda-7f65688ecd92",
|
||||||
|
"DefinitionName": "item_resourceMixtures",
|
||||||
|
"Amount": 235,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "201094-642f6d03-b13d-4f8a-8858-52feb4753ae0",
|
||||||
|
"DefinitionName": "item_alloySteel",
|
||||||
|
"Amount": 10,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2343683-95ecd6be-4668-425f-a935-978f919dd0bf",
|
||||||
|
"DefinitionName": "item_repeaterRifle",
|
||||||
|
"Amount": 2,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "184392-07a9c708-08eb-425f-8099-88f0090c7ae3",
|
||||||
|
"DefinitionName": "item_resourceMetal_t2",
|
||||||
|
"Amount": 522,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "184393-84ff9573-bd57-49d1-a55b-4f498e9bf0c9",
|
||||||
|
"DefinitionName": "item_resourceGunpowder",
|
||||||
|
"Amount": 100,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "184394-5fb05f12-cd7d-4804-ac32-852f7aafd50c",
|
||||||
|
"DefinitionName": "item_resourceReinforcedLeatherStrips",
|
||||||
|
"Amount": 9,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "184396-b5bea1ec-abd4-4832-b6f2-2609e5fb7f49",
|
||||||
|
"DefinitionName": "item_resourceThreads",
|
||||||
|
"Amount": 233,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "184397-fa981224-8a61-4861-ac86-62490b663368",
|
||||||
|
"DefinitionName": "item_resourceFabric",
|
||||||
|
"Amount": 59,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240894-82b2b3c6-08eb-452c-b1da-c8da68d7d022",
|
||||||
|
"DefinitionName": "item_resourceWeaponParts",
|
||||||
|
"Amount": 85,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240895-0ca96c50-6407-48ad-9cf5-add9589999f4",
|
||||||
|
"DefinitionName": "item_resourceMetalParts",
|
||||||
|
"Amount": 236,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240896-761e8ed8-3240-446c-ac05-a2e29a849abf",
|
||||||
|
"DefinitionName": "item_resourceScrappedAmmo",
|
||||||
|
"Amount": 284,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240897-702ac11d-2e24-4b58-9733-25b79aa36d08",
|
||||||
|
"DefinitionName": "item_resourceLeviathanMeat",
|
||||||
|
"Amount": 23,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240899-bf1564d3-8569-4582-b4e6-375693687ae4",
|
||||||
|
"DefinitionName": "item_documentSafe",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": false,
|
||||||
|
"IsLarge": true,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240900-4869f1e8-79b7-4d10-9207-4f59b075b102",
|
||||||
|
"DefinitionName": "item_wineBox",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": false,
|
||||||
|
"IsLarge": true,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240901-4aa97749-c935-4316-a329-5a2a09d7b8fc",
|
||||||
|
"DefinitionName": "Old_JacketT2",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 150
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240904-74e40c86-198a-4b18-971a-4dd35811b38c",
|
||||||
|
"DefinitionName": "item_valuablePapers",
|
||||||
|
"Amount": 156,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 6
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240906-56edf737-b754-4065-8c3c-6d62cfbbb829",
|
||||||
|
"DefinitionName": "item_shotgunAmmo_slug",
|
||||||
|
"Amount": 13,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240907-4ece9ed4-5f6d-4e3b-a020-f8ee88b0d7ba",
|
||||||
|
"DefinitionName": "item_grenadeContact",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "2240908-4b758171-397e-48e1-bb79-e58eb473c367",
|
||||||
|
"DefinitionName": "item_shotgun",
|
||||||
|
"Amount": 1,
|
||||||
|
"SellPrice": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Outfitable": true,
|
||||||
|
"IsLarge": false,
|
||||||
|
"IsExcess": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"NextLevel": {
|
||||||
|
"Level": 1,
|
||||||
|
"AdditionalSlots": 10,
|
||||||
|
"Prices": [
|
||||||
|
{
|
||||||
|
"ItemDefinition": "item_coinCrown",
|
||||||
|
"Amount": 2000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
15
extracted/master_Login.json
Normal file
15
extracted/master_Login.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"_op": "Login",
|
||||||
|
"Result": {
|
||||||
|
"User": {
|
||||||
|
"Id": 1132,
|
||||||
|
"PlayFabId": "42E47F978A8574CD",
|
||||||
|
"PlatformId": {
|
||||||
|
"Platform": "Steam",
|
||||||
|
"Value": "76561198355752256"
|
||||||
|
},
|
||||||
|
"DisplayName": "DownloadPizza"
|
||||||
|
},
|
||||||
|
"SessionTicket": "42E47F978A8574CD-7FD9B6B01144357E-7120042CEAC74317-56693-8DECB00BB6E22BD-rX3mxsq2tsJz4yuP1mqw0VfxDlD9nr/9TW8oZP66enY="
|
||||||
|
}
|
||||||
|
}
|
||||||
5
extracted/playfab_catalog.json
Normal file
5
extracted/playfab_catalog.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"_source": "Client/GetCatalogItems",
|
||||||
|
"count": 0,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
5
extracted/playfab_inventory.json
Normal file
5
extracted/playfab_inventory.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"Inventory": [],
|
||||||
|
"VirtualCurrency": {},
|
||||||
|
"VirtualCurrencyRechargeTimes": {}
|
||||||
|
}
|
||||||
3
extracted/playfab_titledata.json
Normal file
3
extracted/playfab_titledata.json
Normal file
File diff suppressed because one or more lines are too long
3283
extracted/playfab_titledata_TramplerBlueprint3.json
Normal file
3283
extracted/playfab_titledata_TramplerBlueprint3.json
Normal file
File diff suppressed because it is too large
Load Diff
1
extracted/playfab_userdata.json
Normal file
1
extracted/playfab_userdata.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
BIN
extracted/reactor_test.png
Normal file
BIN
extracted/reactor_test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
extracted/test_allhashwiped_4020ff89.wbt
Normal file
BIN
extracted/test_allhashwiped_4020ff89.wbt
Normal file
Binary file not shown.
BIN
extracted/test_nodefhash_6a804acb.wbt
Normal file
BIN
extracted/test_nodefhash_6a804acb.wbt
Normal file
Binary file not shown.
87
extracted/test_nodefhash_baseline.json
Normal file
87
extracted/test_nodefhash_baseline.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"note": "Baseline hash state to diff against after the game loads/re-saves the test walker. Test walker 'Scientific Sentinel' has Definition* hashes wiped to ''. Watch which fields the game fills back in.",
|
||||||
|
"source_01260e82_Cranky_Third": {
|
||||||
|
"DefinitionsHash": "A94FBF71596E2E5FC3595F38C310AB68",
|
||||||
|
"CompartmentsHash": "54319EC1DDFFAF5CAE17A0FA8B0D6750",
|
||||||
|
"ConnectionsHash": "36777484B669177C2B402DC0830EF1BD",
|
||||||
|
"per_part": [
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compChassis_SmallLong4_Metal_2x3_epb",
|
||||||
|
"DefinitionHash": "64677F77ED55788CA579CDC5D5EDC53C",
|
||||||
|
"CompartmentHash": "D1EBE5DDB86BD898DA22A558F5199922"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compSteering_Square_Open_1x1_epb",
|
||||||
|
"DefinitionHash": "8AD614A669C43EC06EC49FB2C0C9F56D",
|
||||||
|
"CompartmentHash": "93901C77B060FA462EB1C415D7304E07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compReactor_Round_Open_2x1_epb",
|
||||||
|
"DefinitionHash": "5744B05118A9CF470EEB77DC2FB8C62D",
|
||||||
|
"CompartmentHash": "BDADD1B66303873E30F8D031EE1F99A1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compCaptainCrew_Wood_1x1_epb",
|
||||||
|
"DefinitionHash": "37F659D0A1EDBFEF11138B059BA87630",
|
||||||
|
"CompartmentHash": "CA0A7339EC20ADAAD07B756BA3206F77"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compDeck_Square_Open_1x1_epb",
|
||||||
|
"DefinitionHash": "04E304846A06AF8E3209DF1054D1E784",
|
||||||
|
"CompartmentHash": "0B8CC69BD1392E6EA7EA37BE5DE6C372"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compDeck_Square_Open_1x1_epb",
|
||||||
|
"DefinitionHash": "04E304846A06AF8E3209DF1054D1E784",
|
||||||
|
"CompartmentHash": "0B8CC69BD1392E6EA7EA37BE5DE6C372"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compSpecial_Entry_Frame_1x1_epb",
|
||||||
|
"DefinitionHash": "CB2209E5C634FBC37C242DE4ED91B70C",
|
||||||
|
"CompartmentHash": "9FB555D20A1BADBBD3AD73DFC989F9B6"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"test_6a804acb_Scientific_Sentinel_NODEFHASH": {
|
||||||
|
"DefinitionsHash": "",
|
||||||
|
"CompartmentsHash": "B0DC03D36083E9A6A7C86DFC1E87D978",
|
||||||
|
"ConnectionsHash": "36777484B669177C2B402DC0830EF1BD",
|
||||||
|
"per_part": [
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compChassis_SmallLong4_Metal_2x3_epb",
|
||||||
|
"DefinitionHash": "",
|
||||||
|
"CompartmentHash": "D1EBE5DDB86BD898DA22A558F5199922"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compSteering_Square_Open_1x1_epb",
|
||||||
|
"DefinitionHash": "",
|
||||||
|
"CompartmentHash": "93901C77B060FA462EB1C415D7304E07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compReactor_Round_Open_2x1_epb",
|
||||||
|
"DefinitionHash": "",
|
||||||
|
"CompartmentHash": "BDADD1B66303873E30F8D031EE1F99A1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compCaptainCrew_Wood_1x1_epb",
|
||||||
|
"DefinitionHash": "",
|
||||||
|
"CompartmentHash": "CA0A7339EC20ADAAD07B756BA3206F77"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compDeck_Square_Open_1x1_epb",
|
||||||
|
"DefinitionHash": "",
|
||||||
|
"CompartmentHash": "0B8CC69BD1392E6EA7EA37BE5DE6C372"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compDeck_Square_Open_1x1_epb",
|
||||||
|
"DefinitionHash": "",
|
||||||
|
"CompartmentHash": "0B8CC69BD1392E6EA7EA37BE5DE6C372"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"EpbId": "walker_compSpecial_Entry_Frame_1x1_epb",
|
||||||
|
"DefinitionHash": "",
|
||||||
|
"CompartmentHash": "9FB555D20A1BADBBD3AD73DFC989F9B6"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
132
reverse/capture_hosts.py
Normal file
132
reverse/capture_hosts.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Quick triage of a SAND network capture: every host the client touched, in order,
|
||||||
|
with the bits we care about highlighted.
|
||||||
|
|
||||||
|
Pulls:
|
||||||
|
- DNS queries (order = startup sequence; flush DNS first for a clean list)
|
||||||
|
- TLS SNI (HTTPS hostnames even when DNS was cached)
|
||||||
|
- TCP/UDP endpoints
|
||||||
|
and flags the three backends we care about:
|
||||||
|
- <titleId>.playfabapi.com -> prints the **PlayFab TitleId** (the one constant the
|
||||||
|
REST scraper needs)
|
||||||
|
- <region>.hologryph.com -> the master-server region (ws://, port 80, cleartext)
|
||||||
|
- sandconfigstorage.blob... -> the anonymous config CDN
|
||||||
|
|
||||||
|
Usage: venv/bin/python reverse/capture_hosts.py <capture.pcapng>
|
||||||
|
"""
|
||||||
|
import sys, re
|
||||||
|
from scapy.all import rdpcap, DNS, DNSQR, DNSRR, IP, IPv6, TCP, UDP, Raw
|
||||||
|
|
||||||
|
|
||||||
|
def tls_sni(b):
|
||||||
|
"""Extract SNI from a TLS ClientHello payload (bytes). None if not a ClientHello."""
|
||||||
|
try:
|
||||||
|
if len(b) < 6 or b[0] != 0x16 or b[5] != 0x01:
|
||||||
|
return None
|
||||||
|
i = 5 + 4 + 2 + 32 # rec hdr + hs hdr + version + random
|
||||||
|
i += 1 + b[i] # session id
|
||||||
|
i += 2 + int.from_bytes(b[i:i + 2], "big") # cipher suites
|
||||||
|
i += 1 + b[i] # compression methods
|
||||||
|
end = i + 2 + int.from_bytes(b[i:i + 2], "big")
|
||||||
|
i += 2
|
||||||
|
while i + 4 <= end:
|
||||||
|
et = int.from_bytes(b[i:i + 2], "big")
|
||||||
|
el = int.from_bytes(b[i + 2:i + 4], "big")
|
||||||
|
i += 4
|
||||||
|
if et == 0: # server_name
|
||||||
|
j = i + 2
|
||||||
|
nl = int.from_bytes(b[j + 1:j + 3], "big")
|
||||||
|
return b[j + 3:j + 3 + nl].decode(errors="replace")
|
||||||
|
i += el
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
PLAYFAB = re.compile(r"^([0-9A-Fa-f]{4,7})\.playfabapi\.com$")
|
||||||
|
HOLOGRYPH = re.compile(r"^([a-z0-9-]+)\.hologryph\.com$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit("usage: capture_hosts.py <pcap>")
|
||||||
|
pk = rdpcap(sys.argv[1])
|
||||||
|
t0 = float(pk[0].time)
|
||||||
|
|
||||||
|
dns_order, dns_seen = [], set()
|
||||||
|
ip2host, snis = {}, {}
|
||||||
|
tcp_first, udp_first = {}, {}
|
||||||
|
|
||||||
|
for p in pk:
|
||||||
|
if p.haslayer(DNS):
|
||||||
|
d = p[DNS]
|
||||||
|
try:
|
||||||
|
qn = p[DNSQR].qname.decode(errors="replace").rstrip(".")
|
||||||
|
except Exception:
|
||||||
|
qn = None
|
||||||
|
if qn and d.qr == 0 and not qn.endswith(".local") and qn != "wpad.localdomain":
|
||||||
|
if qn not in dns_seen:
|
||||||
|
dns_seen.add(qn)
|
||||||
|
dns_order.append((float(p.time) - t0, qn))
|
||||||
|
if qn and d.qr == 1 and d.ancount:
|
||||||
|
for k in range(d.ancount):
|
||||||
|
rr = d.an[k]
|
||||||
|
if rr.type in (1, 28):
|
||||||
|
try:
|
||||||
|
ip2host[str(rr.rdata)] = qn
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ipl = p[IP] if p.haslayer(IP) else (p[IPv6] if p.haslayer(IPv6) else None)
|
||||||
|
if ipl is None:
|
||||||
|
continue
|
||||||
|
if p.haslayer(TCP):
|
||||||
|
t = p[TCP]
|
||||||
|
if t.flags & 0x02 and not t.flags & 0x10:
|
||||||
|
tcp_first.setdefault((ipl.dst, t.dport), float(p.time) - t0)
|
||||||
|
if p.haslayer(Raw) and t.dport == 443:
|
||||||
|
s = tls_sni(bytes(p[Raw].load))
|
||||||
|
if s:
|
||||||
|
snis[ipl.dst] = s
|
||||||
|
elif p.haslayer(UDP):
|
||||||
|
u = p[UDP]
|
||||||
|
if u.dport not in (53, 5353, 1900, 5355, 137) and u.sport not in (53, 5353):
|
||||||
|
key = (ipl.dst, u.dport) if u.dport < u.sport else (ipl.src, u.sport)
|
||||||
|
udp_first.setdefault(key, float(p.time) - t0)
|
||||||
|
|
||||||
|
def label(ip):
|
||||||
|
return snis.get(ip) or ip2host.get(ip, "")
|
||||||
|
|
||||||
|
print("=== DNS queries (in order) ===")
|
||||||
|
for ts, q in dns_order:
|
||||||
|
print(" +%7.2fs %s" % (ts, q))
|
||||||
|
|
||||||
|
print("\n=== TCP destinations (first SYN) ===")
|
||||||
|
for (ip, port), ts in sorted(tcp_first.items(), key=lambda x: x[1]):
|
||||||
|
print(" +%7.2fs %-17s :%-5s %s" % (ts, ip, port, label(ip)))
|
||||||
|
|
||||||
|
print("\n=== UDP destinations ===")
|
||||||
|
for (ip, port), ts in sorted(udp_first.items(), key=lambda x: x[1])[:20]:
|
||||||
|
print(" +%7.2fs %-17s :%-5s %s" % (ts, ip, port, label(ip)))
|
||||||
|
|
||||||
|
# ---- the three backends we care about ----
|
||||||
|
print("\n=== BACKENDS DETECTED ===")
|
||||||
|
allhosts = set(q for _, q in dns_order) | set(snis.values()) | set(ip2host.values())
|
||||||
|
found = False
|
||||||
|
for h in sorted(allhosts):
|
||||||
|
m = PLAYFAB.match(h)
|
||||||
|
if m:
|
||||||
|
print(" PlayFab host=%s ** TitleId = %s **" % (h, m.group(1).upper()))
|
||||||
|
found = True
|
||||||
|
elif HOLOGRYPH.match(h) and "gameclient" not in h:
|
||||||
|
print(" Master server host=%s (region=%s, ws://80 cleartext)"
|
||||||
|
% (h, HOLOGRYPH.match(h).group(1)))
|
||||||
|
found = True
|
||||||
|
elif "sandconfigstorage" in h:
|
||||||
|
print(" Config CDN host=%s (anonymous HTTPS)" % h)
|
||||||
|
found = True
|
||||||
|
if not found:
|
||||||
|
print(" (none of PlayFab / hologryph / config-blob seen — backend not contacted)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
265
reverse/master_scrape.py
Normal file
265
reverse/master_scrape.py
Normal 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()
|
||||||
108
reverse/noise_filter.py
Normal file
108
reverse/noise_filter.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Baseline-subtraction noise filter for SAND captures.
|
||||||
|
|
||||||
|
Idea: capture a short "noise baseline" with SAND NOT running, then capture the real
|
||||||
|
session with SAND running. Every IP/host in the baseline is pre-SAND noise; subtract it
|
||||||
|
and what's left is (almost entirely) the game's traffic.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# 1) just list the noise in a baseline:
|
||||||
|
venv/bin/python reverse/noise_filter.py baseline.pcapng
|
||||||
|
|
||||||
|
# 2) baseline + session -> hosts unique to the session + a ready Wireshark filter:
|
||||||
|
venv/bin/python reverse/noise_filter.py baseline.pcapng session.pcapng
|
||||||
|
|
||||||
|
Outputs, for the session run:
|
||||||
|
- the new (non-noise) IPs/hosts, sorted by traffic volume
|
||||||
|
- a Wireshark *display* filter: ip.addr==X or ip.addr==Y ...
|
||||||
|
- a *capture* (BPF) filter to EXCLUDE noise next time: not (host X or host Y ...)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from scapy.all import rdpcap, DNS, DNSQR, IP, IPv6, TCP, UDP, Raw
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 1)[0])
|
||||||
|
from capture_hosts import tls_sni # reuse the SNI parser
|
||||||
|
|
||||||
|
# hosts/IPs that are never the game, even if they appear only in the session
|
||||||
|
ALWAYS_NOISE_SUBSTR = ("anthropic.com", "datadoghq.com", "windowsupdate", "msftncsi",
|
||||||
|
"msftconnecttest", "ntp.", ".pool.ntp.org")
|
||||||
|
|
||||||
|
|
||||||
|
def scan(path):
|
||||||
|
"""Return (ip_volume, ip2host) for a pcap.
|
||||||
|
ip_volume[ip] = packet count to/from that remote ip; ip2host[ip] = best label."""
|
||||||
|
pk = rdpcap(path)
|
||||||
|
vol = defaultdict(int)
|
||||||
|
ip2host, dns = {}, {}
|
||||||
|
# learn DNS answers (qname for an ip) and SNI
|
||||||
|
for p in pk:
|
||||||
|
if p.haslayer(DNS) and p[DNS].qr == 1 and p[DNS].ancount:
|
||||||
|
try:
|
||||||
|
qn = p[DNSQR].qname.decode(errors="replace").rstrip(".")
|
||||||
|
for k in range(p[DNS].ancount):
|
||||||
|
rr = p[DNS].an[k]
|
||||||
|
if rr.type in (1, 28):
|
||||||
|
dns[str(rr.rdata)] = qn
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if p.haslayer(TCP) and p.haslayer(Raw):
|
||||||
|
s = tls_sni(bytes(p[Raw].load))
|
||||||
|
if s and (p.haslayer(IP) or p.haslayer(IPv6)):
|
||||||
|
ipl = p[IP] if p.haslayer(IP) else p[IPv6]
|
||||||
|
ip2host[ipl.dst] = s
|
||||||
|
ipl = p[IP] if p.haslayer(IP) else (p[IPv6] if p.haslayer(IPv6) else None)
|
||||||
|
if ipl is None:
|
||||||
|
continue
|
||||||
|
for ip in (ipl.src, ipl.dst):
|
||||||
|
if not is_local(ip):
|
||||||
|
vol[ip] += 1
|
||||||
|
for ip in vol:
|
||||||
|
ip2host.setdefault(ip, dns.get(ip, ""))
|
||||||
|
return vol, ip2host
|
||||||
|
|
||||||
|
|
||||||
|
def is_local(ip):
|
||||||
|
return (ip.startswith(("10.", "192.168.", "127.", "169.254.", "fe80:", "ff", "::1"))
|
||||||
|
or ip.startswith("172.") and 16 <= int(ip.split(".")[1] or 0) <= 31
|
||||||
|
or ip in ("0.0.0.0",) or ip.endswith(".255"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
base_vol, base_host = scan(sys.argv[1])
|
||||||
|
noise = set(base_vol)
|
||||||
|
print("=== baseline noise: %d remote IPs ===" % len(noise))
|
||||||
|
for ip, n in sorted(base_vol.items(), key=lambda x: -x[1]):
|
||||||
|
print(" %-16s %-6d %s" % (ip, n, base_host.get(ip, "")))
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("\n(pass a second pcap to diff a real session against this baseline)")
|
||||||
|
return
|
||||||
|
|
||||||
|
sess_vol, sess_host = scan(sys.argv[2])
|
||||||
|
# a session ip is "game" if not in baseline and not on the always-noise list
|
||||||
|
def always_noise(ip):
|
||||||
|
h = sess_host.get(ip, "")
|
||||||
|
return any(s in h for s in ALWAYS_NOISE_SUBSTR)
|
||||||
|
|
||||||
|
new = {ip: n for ip, n in sess_vol.items()
|
||||||
|
if ip not in noise and not always_noise(ip)}
|
||||||
|
print("\n=== session-only hosts (candidate SAND backends) ===")
|
||||||
|
for ip, n in sorted(new.items(), key=lambda x: -x[1]):
|
||||||
|
print(" %-16s %-6d %s" % (ip, n, sess_host.get(ip, "")))
|
||||||
|
if not new:
|
||||||
|
print(" (nothing new — either SAND made no new connections, or it reused a "
|
||||||
|
"baseline IP/CDN; widen the gap or capture longer)")
|
||||||
|
return
|
||||||
|
|
||||||
|
ips = sorted(new)
|
||||||
|
print("\n--- Wireshark DISPLAY filter (keep only SAND) ---")
|
||||||
|
print(" " + " or ".join("ip.addr==%s" % ip for ip in ips))
|
||||||
|
print("\n--- Wireshark CAPTURE filter (BPF, EXCLUDE noise next time) ---")
|
||||||
|
print(" not (" + " or ".join("host %s" % ip for ip in sorted(noise)) + ")")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
184
reverse/playfab_scrape.py
Normal file
184
reverse/playfab_scrape.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scrape SAND's PlayFab Economy (catalog + prices + inventory) via the public REST API.
|
||||||
|
|
||||||
|
Backend is Azure PlayFab (Economy v2). Auth is `LoginWithSteam`. Everything here is the
|
||||||
|
documented PlayFab REST surface (https://learn.microsoft.com/gaming/playfab/), run from
|
||||||
|
*outside* the game process — no BattlEye interaction.
|
||||||
|
|
||||||
|
Two ways in:
|
||||||
|
--steam-ticket <hex> do Client/LoginWithSteam to get a fresh SessionTicket + EntityToken
|
||||||
|
--entity-token <tok> skip login, use an EntityToken you captured (e.g. from your MITM)
|
||||||
|
|
||||||
|
The only constant you must supply is --title-id (the PlayFab TitleId). Get it from a live
|
||||||
|
capture: `reverse/capture_hosts.py` prints it from the `<titleId>.playfabapi.com` host.
|
||||||
|
|
||||||
|
Modes (combine freely):
|
||||||
|
--catalog Catalog/SearchItems -> every item with PriceOptions + DisplayProperties
|
||||||
|
--inventory Inventory/GetInventoryItems + GetTransactionHistory (your wallet/items)
|
||||||
|
--titledata Client/GetTitleData (config key/values, if any)
|
||||||
|
|
||||||
|
Read-only endpoints only. Output -> extracted/playfab_<mode>.json, item ids resolved to names.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
venv/bin/python reverse/playfab_scrape.py --title-id ABCDE --steam-ticket 14000000... --catalog
|
||||||
|
"""
|
||||||
|
import sys, os, json, argparse
|
||||||
|
import requests
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
NAMES_PATH = os.path.join(ROOT, "extracted", "item_names.json")
|
||||||
|
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
|
||||||
|
class PlayFab:
|
||||||
|
def __init__(self, title_id):
|
||||||
|
self.title = title_id
|
||||||
|
self.base = "https://%s.playfabapi.com" % title_id
|
||||||
|
self.session_ticket = None
|
||||||
|
self.entity_token = None
|
||||||
|
self.s = requests.Session()
|
||||||
|
self.s.headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
def _post(self, path, body, headers=None):
|
||||||
|
r = self.s.post(self.base + path, data=json.dumps(body),
|
||||||
|
headers=headers or {}, timeout=30)
|
||||||
|
try:
|
||||||
|
j = r.json()
|
||||||
|
except Exception:
|
||||||
|
r.raise_for_status()
|
||||||
|
raise
|
||||||
|
if r.status_code != 200 or j.get("code") not in (200, None):
|
||||||
|
raise RuntimeError("%s -> %s %s" % (path, r.status_code,
|
||||||
|
j.get("errorMessage") or j.get("error") or j))
|
||||||
|
return j.get("data", j)
|
||||||
|
|
||||||
|
def login_steam(self, ticket_hex, service_specific=False):
|
||||||
|
body = {"TitleId": self.title, "SteamTicket": ticket_hex,
|
||||||
|
"CreateAccount": False, "TicketIsServiceSpecific": service_specific}
|
||||||
|
d = self._post("/Client/LoginWithSteam", body)
|
||||||
|
self.session_ticket = d.get("SessionTicket")
|
||||||
|
et = d.get("EntityToken") or {}
|
||||||
|
self.entity_token = et.get("EntityToken")
|
||||||
|
print(" logged in: PlayFabId=%s entity=%s" %
|
||||||
|
(d.get("PlayFabId"), (et.get("Entity") or {}).get("Id")))
|
||||||
|
return d
|
||||||
|
|
||||||
|
# ---- Economy v2 (Entity API; needs X-EntityToken) ----
|
||||||
|
def search_items(self):
|
||||||
|
items, token = [], None
|
||||||
|
while True:
|
||||||
|
body = {"Count": 50, "Filter": "", "Search": ""}
|
||||||
|
if token:
|
||||||
|
body["ContinuationToken"] = token
|
||||||
|
d = self._post("/Catalog/SearchItems", body,
|
||||||
|
headers={"X-EntityToken": self.entity_token})
|
||||||
|
items += d.get("Items", [])
|
||||||
|
token = d.get("ContinuationToken")
|
||||||
|
print(" catalog: %d items so far..." % len(items))
|
||||||
|
if not token:
|
||||||
|
break
|
||||||
|
return items
|
||||||
|
|
||||||
|
def inventory(self):
|
||||||
|
out = {}
|
||||||
|
out["items"] = self._post("/Inventory/GetInventoryItems", {"Count": 50},
|
||||||
|
headers={"X-EntityToken": self.entity_token}).get("Items", [])
|
||||||
|
try:
|
||||||
|
out["transactions"] = self._post("/Inventory/GetTransactionHistory", {"Count": 50},
|
||||||
|
headers={"X-EntityToken": self.entity_token}).get("Transactions", [])
|
||||||
|
except Exception as e:
|
||||||
|
out["transactions_error"] = str(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def title_data(self):
|
||||||
|
return self._post("/Client/GetTitleData", {},
|
||||||
|
headers={"X-Authorization": self.session_ticket}).get("Data", {})
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_catalog(items, names):
|
||||||
|
"""Flatten each catalog item to {id, friendlyId, name, type, prices, displayProperties}."""
|
||||||
|
rows = []
|
||||||
|
for it in items:
|
||||||
|
alt = {a.get("Type"): a.get("Value") for a in it.get("AlternateIds", [])}
|
||||||
|
fid = alt.get("FriendlyId") or it.get("Id")
|
||||||
|
prices = []
|
||||||
|
for po in (it.get("PriceOptions") or {}).get("Prices", []):
|
||||||
|
legs = [{"currency": a.get("ItemId"),
|
||||||
|
"currencyName": names.get(a.get("ItemId"), a.get("ItemId")),
|
||||||
|
"amount": a.get("Amount")} for a in po.get("Amounts", [])]
|
||||||
|
prices.append(legs)
|
||||||
|
title = it.get("Title", {})
|
||||||
|
rows.append({
|
||||||
|
"id": it.get("Id"),
|
||||||
|
"friendlyId": fid,
|
||||||
|
"name": (title.get("NEUTRAL") or names.get(fid) or fid),
|
||||||
|
"type": it.get("Type"),
|
||||||
|
"contentType": it.get("ContentType"),
|
||||||
|
"prices": prices,
|
||||||
|
"displayProperties": it.get("DisplayProperties"),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--title-id", required=True)
|
||||||
|
ap.add_argument("--steam-ticket")
|
||||||
|
ap.add_argument("--entity-token")
|
||||||
|
ap.add_argument("--ticket-service-specific", action="store_true")
|
||||||
|
ap.add_argument("--catalog", action="store_true")
|
||||||
|
ap.add_argument("--inventory", action="store_true")
|
||||||
|
ap.add_argument("--titledata", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
if not (args.steam_ticket or args.entity_token):
|
||||||
|
sys.exit("need --steam-ticket OR --entity-token")
|
||||||
|
if not (args.catalog or args.inventory or args.titledata):
|
||||||
|
args.catalog = True # default
|
||||||
|
|
||||||
|
names = load_names()
|
||||||
|
pf = PlayFab(args.title_id)
|
||||||
|
if args.entity_token:
|
||||||
|
pf.entity_token = args.entity_token
|
||||||
|
print(" using captured EntityToken")
|
||||||
|
if args.steam_ticket:
|
||||||
|
pf.login_steam(args.steam_ticket, args.ticket_service_specific)
|
||||||
|
|
||||||
|
os.makedirs(os.path.join(ROOT, "extracted"), exist_ok=True)
|
||||||
|
|
||||||
|
if args.catalog:
|
||||||
|
items = pf.search_items()
|
||||||
|
rows = summarize_catalog(items, names)
|
||||||
|
out = os.path.join(ROOT, "extracted", "playfab_catalog.json")
|
||||||
|
json.dump({"_source": "PlayFab Catalog/SearchItems", "count": len(rows),
|
||||||
|
"items_raw": items, "items": rows}, open(out, "w"),
|
||||||
|
indent=1, ensure_ascii=False)
|
||||||
|
print("wrote %s (%d items)" % (out, len(rows)))
|
||||||
|
priced = [r for r in rows if r["prices"]]
|
||||||
|
print(" %d items carry a price. sample:" % len(priced))
|
||||||
|
for r in priced[:10]:
|
||||||
|
pr = " | ".join(" + ".join("%s %s" % (l["amount"], l["currencyName"]) for l in legs)
|
||||||
|
for legs in r["prices"])
|
||||||
|
print(" %-32s %s" % (r["name"], pr))
|
||||||
|
|
||||||
|
if args.inventory and pf.entity_token:
|
||||||
|
inv = pf.inventory()
|
||||||
|
out = os.path.join(ROOT, "extracted", "playfab_inventory.json")
|
||||||
|
json.dump(inv, open(out, "w"), indent=1, ensure_ascii=False)
|
||||||
|
print("wrote", out)
|
||||||
|
|
||||||
|
if args.titledata and pf.session_ticket:
|
||||||
|
td = pf.title_data()
|
||||||
|
out = os.path.join(ROOT, "extracted", "playfab_titledata.json")
|
||||||
|
json.dump(td, open(out, "w"), indent=1, ensure_ascii=False)
|
||||||
|
print("wrote %s (%d keys)" % (out, len(td)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
95
reverse/render_trampler.py
Normal file
95
reverse/render_trampler.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Strictly data-derived: footprints (CompartmentsDatabase cells + blueprint rotation/origin),
|
||||||
|
# doors/hatches (blueprint Connections), single-tile guns. NO firing arcs: the facing/aim is
|
||||||
|
# NOT present in the data (no forward/aim/angle field), so it is intentionally omitted.
|
||||||
|
import json, collections, os, re
|
||||||
|
import math
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
bp=json.load(open(ROOT+"/extracted/host_trampler_blueprint.json"))
|
||||||
|
db={c["entityId"]:c for c in json.load(open(ROOT+"/extracted/CompartmentsDatabase.json"))["compartments"]}
|
||||||
|
def rot(x,z,deg):
|
||||||
|
deg%=360; return {0:(x,z),90:(z,-x),180:(-x,-z),270:(-z,x)}[deg]
|
||||||
|
def cat(e):
|
||||||
|
m=re.match(r"walker_comp([A-Za-z]+)_",e or ""); return m.group(1) if m else "?"
|
||||||
|
CC={"Chassis":(90,90,100),"Reactor":(255,140,0),"Engine":(210,60,45),"Weapon":(170,40,60),
|
||||||
|
"Armor":(70,110,165),"Cargo":(210,170,60),"Crew":(70,165,85),"CaptainCrew":(30,110,55),
|
||||||
|
"Deck":(205,200,185),"Corridor":(160,205,228),"Special":(150,80,190),"Crafting":(150,100,60),
|
||||||
|
"Steering":(40,165,165),"Structure":(150,150,160),"Balcony":(190,170,140)}
|
||||||
|
LET={k:k[0] for k in CC}; LET.update({"CaptainCrew":"K","Corridor":"+","Crew":"c","Crafting":"F","Steering":"T","Chassis":"#","Weapon":"W"})
|
||||||
|
parts=[bp["Chassis"]]+bp["Compartments"]
|
||||||
|
def is_turret(e): return e.startswith("walker_compWeapon_TurretSlot")
|
||||||
|
def is_ram(e): return "BattleRam" in e
|
||||||
|
floors=collections.defaultdict(dict); labels=collections.defaultdict(list); turrets=[]; cargo_n=0
|
||||||
|
for p in parts:
|
||||||
|
e=db.get(p["EpbId"]);
|
||||||
|
if not e: continue
|
||||||
|
eid=p["EpbId"]; ox,oy,oz=p["CellCoordinate"]["x"],p["CellCoordinate"]["y"],p["CellCoordinate"]["z"]; deg=int(p.get("Rotation",0)); c=cat(eid)
|
||||||
|
if is_turret(eid):
|
||||||
|
floors[oy][(ox,oz)]=(c,True); labels[oy].append((ox,oz,"W"))
|
||||||
|
base=135 if "Corner" in eid else 90 # rot0: corner=SW, straight=S (your calibration)
|
||||||
|
turrets.append((oy,ox,oz,(base-deg)%360)); continue
|
||||||
|
cells=[]
|
||||||
|
for cl in e["cells"]:
|
||||||
|
rx,rz=rot(cl["position"]["x"],cl["position"]["z"],deg); wy=oy+cl["position"]["y"]
|
||||||
|
floors[wy].setdefault((ox+rx,oz+rz),(c,bool(cl.get("volumeOccupied"))))
|
||||||
|
if cl.get("volumeOccupied") and wy==oy: cells.append((ox+rx,oz+rz))
|
||||||
|
if is_ram(eid):
|
||||||
|
for (x,z) in cells: floors[oy][(x,z)]=(c,True)
|
||||||
|
cx=sum(x for x,z in cells)/max(1,len(cells)); cz=sum(z for x,z in cells)/max(1,len(cells)); labels[oy].append((cx,cz,"R"))
|
||||||
|
elif c=="Cargo":
|
||||||
|
cargo_n+=1; labels[oy].append((ox,oz,"C%d"%cargo_n))
|
||||||
|
else:
|
||||||
|
labels[oy].append((ox,oz,LET.get(c,"?")))
|
||||||
|
doors=collections.defaultdict(list); hatches=collections.defaultdict(list)
|
||||||
|
for cn in bp["Connections"]:
|
||||||
|
g=cn["GridCoordinate"]; d=cn["ActualDirection"]; sl=cn["SlotType"]; st=cn["State"]
|
||||||
|
if sl=="DOOR" and st in ("DOOR","OPEN") and d["y"]==0: doors[g["y"]].append((g["x"],g["z"],d["x"],d["z"],st))
|
||||||
|
if sl=="HATCH" and st=="DOOR": hatches[g["y"]].append((g["x"],g["z"],d["y"]))
|
||||||
|
allc=[(x,z) for y in floors if -1<=y<=3 for (x,z) in floors[y]]
|
||||||
|
xmin=min(x for x,z in allc); xmax=max(x for x,z in allc); zmin=min(z for x,z in allc); zmax=max(z for x,z in allc)
|
||||||
|
W=xmax-xmin+1; H=zmax-zmin+1; CELL=46; PAD=14; GAP=22; TITLEH=24
|
||||||
|
order=[y for y in sorted(floors,reverse=True) if -1<=y<=3]
|
||||||
|
FN={3:"deck 3",2:"deck 2",1:"deck 1",0:"deck 0",-1:"base / hull"}
|
||||||
|
panelW=W*CELL; panelH=H*CELL; cols=len(order)
|
||||||
|
imgW=PAD+cols*(panelW+GAP); imgH=PAD+TITLEH*2+panelH+120
|
||||||
|
img=Image.new("RGBA",(imgW,imgH),(245,245,248,255)); ov=Image.new("RGBA",img.size,(0,0,0,0)); d=ImageDraw.Draw(img); od=ImageDraw.Draw(ov)
|
||||||
|
f=ImageFont.load_default(size=18); fs=ImageFont.load_default(size=12); ft=ImageFont.load_default(size=15)
|
||||||
|
d.text((PAD,4),"Host trampler - data-derived: footprints, doors/hatches, single-tile guns (W). Gun arc=rotation field (corner=45/straight=cardinal, +-90). N=up",fill=(20,20,30),font=ft)
|
||||||
|
for i,y in enumerate(order):
|
||||||
|
ox0=PAD+i*(panelW+GAP); oy0=PAD+TITLEH*2
|
||||||
|
d.text((ox0,oy0-18),FN.get(y,"y%d"%y),fill=(20,20,30),font=ft)
|
||||||
|
def px(x,z): return ox0+(x-xmin)*CELL, oy0+(z-zmin)*CELL
|
||||||
|
for zi in range(H):
|
||||||
|
for xi in range(W):
|
||||||
|
X0=ox0+xi*CELL; Y0=oy0+zi*CELL; d.rectangle([X0,Y0,X0+CELL,Y0+CELL],outline=(225,225,228))
|
||||||
|
for (x,z),(c,solid) in floors[y].items():
|
||||||
|
X0,Y0=px(x,z); col=CC.get(c,(120,120,120))
|
||||||
|
if solid: d.rectangle([X0+1,Y0+1,X0+CELL-1,Y0+CELL-1],fill=col,outline=(40,40,40))
|
||||||
|
else: d.rectangle([X0+4,Y0+4,X0+CELL-4,Y0+CELL-4],fill=tuple(min(255,v+70) for v in col),outline=col)
|
||||||
|
for (cx,cz,let) in labels[y]:
|
||||||
|
X0,Y0=px(cx,cz); d.text((X0+CELL/2-5*len(let),Y0+CELL/2-9),let,fill=(255,255,255),font=f)
|
||||||
|
for (x,z,dx,dz,st) in doors.get(y,[]):
|
||||||
|
X0,Y0=px(x,z); col=(60,200,90) if st=="OPEN" else (130,80,30)
|
||||||
|
if dx==1: d.line([X0+CELL,Y0+8,X0+CELL,Y0+CELL-8],fill=col,width=5)
|
||||||
|
if dx==-1: d.line([X0,Y0+8,X0,Y0+CELL-8],fill=col,width=5)
|
||||||
|
if dz==1: d.line([X0+8,Y0+CELL,X0+CELL-8,Y0+CELL],fill=col,width=5)
|
||||||
|
if dz==-1: d.line([X0+8,Y0,X0+CELL-8,Y0],fill=col,width=5)
|
||||||
|
for (x,z,dy) in hatches.get(y,[]):
|
||||||
|
X0,Y0=px(x,z); cx,cy=X0+CELL/2,Y0+CELL/2; d.ellipse([cx-8,cy-8,cx+8,cy+8],outline=(20,20,140),width=2)
|
||||||
|
d.text((cx-3,cy-7),"^" if dy==1 else "v",fill=(20,20,140),font=fs)
|
||||||
|
for (ty,tx,tz,face) in turrets:
|
||||||
|
if ty!=y: continue
|
||||||
|
X0,Y0=px(tx,tz); cx,cy=X0+CELL/2,Y0+CELL/2; R=CELL*0.75
|
||||||
|
od.pieslice([cx-R,cy-R,cx+R,cy+R],face-90,face+90,fill=(255,210,40,75),outline=(230,160,0,200))
|
||||||
|
img=Image.alpha_composite(img,ov); d=ImageDraw.Draw(img)
|
||||||
|
ly=PAD+TITLEH*2+panelH+10; d.text((PAD,ly-2),"LEGEND:",fill=(20,20,30),font=ft); ly+=18; lx=PAD
|
||||||
|
for k in CC:
|
||||||
|
d.rectangle([lx,ly,lx+20,ly+20],fill=CC[k],outline=(30,30,30)); d.text((lx+4,ly+4),LET.get(k,"?"),fill=(255,255,255),font=fs)
|
||||||
|
d.text((lx+26,ly+4),k,fill=(20,20,30),font=fs); lx+=26+len(k)*7+18
|
||||||
|
if lx>imgW-150: lx=PAD; ly+=26
|
||||||
|
ly+=8
|
||||||
|
d.line([PAD,ly,PAD+40,ly],fill=(130,80,30),width=5); d.text((PAD+48,ly-7),"door",fill=(20,20,30),font=fs)
|
||||||
|
d.line([PAD+100,ly,PAD+140,ly],fill=(60,200,90),width=5); d.text((PAD+148,ly-7),"open door",fill=(20,20,30),font=fs)
|
||||||
|
d.ellipse([PAD+230,ly-8,PAD+246,ly+8],outline=(20,20,140),width=2); d.text((PAD+254,ly-7),"hatch (^/v) W=gun R=ram yellow wedge=firing arc (+-90) faded=non-solid",fill=(20,20,30),font=fs)
|
||||||
|
img.convert("RGB").save(ROOT+"/extracted/host_trampler_nice.png"); print("saved with rotation arcs, turrets=%d"%len(turrets))
|
||||||
66
reverse/resolve_decomp.py
Normal file
66
reverse/resolve_decomp.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Annotate ghidra/decomp.c: resolve absolute VAs (0x180........) to IL2CPP symbol names
|
||||||
|
(methods.tsv) and string literals (strings.tsv). Ghidra image base = 0x180000000.
|
||||||
|
|
||||||
|
methods.tsv : <rva-decimal>\t<symbol> (rva = scriptAddress - 0x1000)
|
||||||
|
strings.tsv : <scriptAddress-decimal>\t<str> (raw ScriptString.Address)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
venv/bin/python reverse/resolve_decomp.py # writes ghidra/decomp.annotated.c
|
||||||
|
venv/bin/python reverse/resolve_decomp.py <substr> # print only funcs whose name matches substr
|
||||||
|
"""
|
||||||
|
import re, sys, os
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
G = os.path.join(ROOT, "ghidra")
|
||||||
|
BASE = 0x180000000
|
||||||
|
|
||||||
|
def load_methods():
|
||||||
|
m = {}
|
||||||
|
for l in open(os.path.join(G, "methods.tsv")):
|
||||||
|
if "\t" in l:
|
||||||
|
rva, name = l.rstrip("\n").split("\t", 1)
|
||||||
|
m[int(rva)] = name
|
||||||
|
return m
|
||||||
|
|
||||||
|
def load_strings():
|
||||||
|
s = {}
|
||||||
|
p = os.path.join(G, "strings.tsv")
|
||||||
|
if os.path.exists(p):
|
||||||
|
for l in open(p):
|
||||||
|
if "\t" in l:
|
||||||
|
a, v = l.rstrip("\n").split("\t", 1)
|
||||||
|
s[int(a)] = v
|
||||||
|
return s
|
||||||
|
|
||||||
|
def main():
|
||||||
|
methods = load_methods()
|
||||||
|
strings = load_strings()
|
||||||
|
src = open(os.path.join(G, "decomp.c"), encoding="utf-8", errors="replace").read()
|
||||||
|
hexre = re.compile(r"0x(1[0-9a-fA-F]{8,9})")
|
||||||
|
|
||||||
|
def repl(m):
|
||||||
|
va = int(m.group(1), 16)
|
||||||
|
rva = va - BASE
|
||||||
|
if rva in methods:
|
||||||
|
return "%s/*%s*/" % (m.group(0), methods[rva])
|
||||||
|
# string literal: try raw rva, rva+0x1000 (scriptAddress), and the table conventions
|
||||||
|
for cand in (rva, rva + 0x1000, va, va - BASE + 0x1000):
|
||||||
|
if cand in strings:
|
||||||
|
return '%s/*"%s"*/' % (m.group(0), strings[cand][:60])
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
out = hexre.sub(repl, src)
|
||||||
|
outp = os.path.join(G, "decomp.annotated.c")
|
||||||
|
open(outp, "w", encoding="utf-8").write(out)
|
||||||
|
nres = out.count("/*")
|
||||||
|
print("wrote %s (%d annotations)" % (outp, nres))
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
sub = sys.argv[1]
|
||||||
|
blocks = re.split(r"(?=// ==== )", out)
|
||||||
|
for b in blocks:
|
||||||
|
if sub.lower() in b[:200].lower():
|
||||||
|
print(b)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
76
reverse/trampler_hashes.py
Normal file
76
reverse/trampler_hashes.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the SAND trampler/walker blueprint hashes FROM SCRATCH (no harvesting needed).
|
||||||
|
|
||||||
|
RE'd from GameAssembly.dll (2026-06-15 build):
|
||||||
|
MD5Utility.ComputeHash(obj) = MD5( UTF8( JsonConvert.SerializeObject(obj) ) ), formatted as
|
||||||
|
uppercase hex with no separators (each byte -> ToString("X2")). [ghidra: MD5Utility$$ObjectToByteArray,
|
||||||
|
$$ComputeHash]
|
||||||
|
|
||||||
|
The three top-level WalkerBlueprintDto hashes are MD5 of the *compact* JSON (Newtonsoft default:
|
||||||
|
no whitespace, PascalCase keys in C# declaration order, nulls included, enums as STRING names,
|
||||||
|
CellCoordinate as {x,y,z}) of:
|
||||||
|
CompartmentsHash = ComputeHash(blueprint.Compartments) [List<CompartmentBlueprintDto>] VERIFIED
|
||||||
|
ConnectionsHash = ComputeHash(blueprint.Connections) [List<ConnectionBlueprintDto>] VERIFIED <- "hash #3"
|
||||||
|
DefinitionsHash = ComputeHash(<the CompartmentDefinitionDto list for the compartments>) PROVISIONAL
|
||||||
|
(hashes the actual definitions, not blueprint-internal fields; verify live
|
||||||
|
against a fresh GetTrampler + GetCompartmentDefinitions once unfrozen.)
|
||||||
|
|
||||||
|
To match Newtonsoft byte-for-byte, build the dicts with keys already in declaration order and
|
||||||
|
serialize with json.dumps(obj, separators=(",",":"), ensure_ascii=False). Do NOT sort keys.
|
||||||
|
"""
|
||||||
|
import json, hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def md5_hash(obj) -> str:
|
||||||
|
"""MD5Utility.ComputeHash(obj): MD5 of compact-JSON(obj), uppercase hex."""
|
||||||
|
blob = json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
return hashlib.md5(blob.encode("utf-8")).hexdigest().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def compartments_hash(compartments: list) -> str:
|
||||||
|
"""#1 CompartmentsHash = MD5(JSON(Compartments list)). VERIFIED."""
|
||||||
|
return md5_hash(compartments)
|
||||||
|
|
||||||
|
|
||||||
|
def connections_hash(connections: list) -> str:
|
||||||
|
"""#3 ConnectionsHash = MD5(JSON(Connections list)). VERIFIED.
|
||||||
|
|
||||||
|
Each connection must serialize as (declaration order):
|
||||||
|
{"Id": int, "EpbId": str|None, "ActualDirection": {"x":,"y":,"z":},
|
||||||
|
"GridCoordinate": {"x":,"y":,"z":}, "SlotType": "<name>", "State": "<name>"}
|
||||||
|
"""
|
||||||
|
return md5_hash(connections)
|
||||||
|
|
||||||
|
|
||||||
|
def definitions_hash(definitions: list) -> str:
|
||||||
|
"""#2 DefinitionsHash = MD5(JSON(<CompartmentDefinitionDto list>)). PROVISIONAL — verify live."""
|
||||||
|
return md5_hash(definitions)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_hashes(blueprint: dict, definitions: list = None) -> dict:
|
||||||
|
"""Set the three top-level hashes on a blueprint dict in place. Returns it.
|
||||||
|
definitions: ordered CompartmentDefinitionDto list for #2 (optional until verified)."""
|
||||||
|
blueprint["CompartmentsHash"] = compartments_hash(blueprint["Compartments"])
|
||||||
|
blueprint["ConnectionsHash"] = connections_hash(blueprint["Connections"])
|
||||||
|
if definitions is not None:
|
||||||
|
blueprint["DefinitionsHash"] = definitions_hash(definitions)
|
||||||
|
return blueprint
|
||||||
|
|
||||||
|
|
||||||
|
def _selftest():
|
||||||
|
import os
|
||||||
|
p = os.path.join(os.path.dirname(__file__), "..", "extracted",
|
||||||
|
"playfab_titledata_TramplerBlueprint3.json")
|
||||||
|
b = json.load(open(p))
|
||||||
|
c1 = compartments_hash(b["Compartments"])
|
||||||
|
c3 = connections_hash(b["Connections"])
|
||||||
|
assert c1 == b["CompartmentsHash"], "CompartmentsHash %s != %s" % (c1, b["CompartmentsHash"])
|
||||||
|
assert c3 == b["ConnectionsHash"], "ConnectionsHash %s != %s" % (c3, b["ConnectionsHash"])
|
||||||
|
print("selftest OK:")
|
||||||
|
print(" CompartmentsHash #1:", c1, "== stored")
|
||||||
|
print(" ConnectionsHash #3:", c3, "== stored <- generatable from scratch")
|
||||||
|
print(" DefinitionsHash #2: provisional (verify live vs current GetTrampler).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_selftest()
|
||||||
138
reverse/walkerdto_to_blueprint.py
Normal file
138
reverse/walkerdto_to_blueprint.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Convert a master-server WalkerDto (e.g. ExpeditionDto.Trampler, from GetExpedition /
|
||||||
|
GetExpeditionWalker) into a loadable WalkerBlueprintDto, recomputing the 3 top-level hashes.
|
||||||
|
|
||||||
|
WHY a transform is needed: the WS channel serializes enums as INTEGERS and omits null EpbId on
|
||||||
|
connections; the blueprint/hash ("storage") form uses enum NAME strings and includes EpbId:null.
|
||||||
|
ObjectToByteArray hashes the storage form (verified in trampler_hashes.py). So WS-form -> storage-form
|
||||||
|
before hashing.
|
||||||
|
|
||||||
|
Enum maps (from il2cpp/dump.cs):
|
||||||
|
ConnectionSlotType: DOOR=0 HATCH=1 STRUCTURE=2 BALCONY=3 DECK=4
|
||||||
|
ConnectionState : DEFAULT=0 DOOR=1 OPEN=2 (MasterserverDtos.ConnectionState, 35406)
|
||||||
|
ConnectionsCount : FULL=0 PARTIAL=1 ERROR=2
|
||||||
|
|
||||||
|
Self-verifies offline via round-trip on the known sample (storage->WS->storage is identity and
|
||||||
|
reproduces the stored CompartmentsHash/ConnectionsHash).
|
||||||
|
"""
|
||||||
|
import json, os, sys
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from trampler_hashes import compartments_hash, connections_hash, definitions_hash
|
||||||
|
|
||||||
|
SLOT = ["DOOR", "HATCH", "STRUCTURE", "BALCONY", "DECK"] # ConnectionSlotType
|
||||||
|
STATE = ["DEFAULT", "DOOR", "OPEN"] # ConnectionState
|
||||||
|
COUNT = ["FULL", "PARTIAL", "ERROR"] # ConnectionsCount
|
||||||
|
SLOT_I = {n: i for i, n in enumerate(SLOT)}
|
||||||
|
STATE_I = {n: i for i, n in enumerate(STATE)}
|
||||||
|
COUNT_I = {n: i for i, n in enumerate(COUNT)}
|
||||||
|
|
||||||
|
|
||||||
|
def _od(*pairs):
|
||||||
|
"""build a dict in explicit (declaration) order."""
|
||||||
|
d = {}
|
||||||
|
for k, v in pairs:
|
||||||
|
d[k] = v
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _decor_i2s(decor):
|
||||||
|
"""DecorationsInfo: socket enums int->name (storage form)."""
|
||||||
|
if decor is None:
|
||||||
|
return None
|
||||||
|
socks = []
|
||||||
|
for s in decor.get("Sockets", []):
|
||||||
|
vals = []
|
||||||
|
for kv in s.get("Value", []):
|
||||||
|
v = kv.get("Value") or {}
|
||||||
|
vals.append(_od(("Key", SLOT[kv["Key"]]),
|
||||||
|
("Value", _od(("state", STATE[v["state"]]), ("count", COUNT[v["count"]])))))
|
||||||
|
socks.append(_od(("Key", s["Key"]), ("Value", vals)))
|
||||||
|
return _od(("Sockets", socks))
|
||||||
|
|
||||||
|
|
||||||
|
def _decor_s2i(decor):
|
||||||
|
"""inverse, for round-trip self-test only."""
|
||||||
|
if decor is None:
|
||||||
|
return None
|
||||||
|
socks = []
|
||||||
|
for s in decor.get("Sockets", []):
|
||||||
|
vals = []
|
||||||
|
for kv in s.get("Value", []):
|
||||||
|
v = kv.get("Value") or {}
|
||||||
|
vals.append(_od(("Key", SLOT_I[kv["Key"]]),
|
||||||
|
("Value", _od(("state", STATE_I[v["state"]]), ("count", COUNT_I[v["count"]])))))
|
||||||
|
socks.append(_od(("Key", s["Key"]), ("Value", vals)))
|
||||||
|
return _od(("Sockets", socks))
|
||||||
|
|
||||||
|
|
||||||
|
def comp_i2s(bp):
|
||||||
|
"""one CompartmentBlueprintDto WS->storage (CompartmentBlueprintDto field order)."""
|
||||||
|
return _od(("Id", bp["Id"]), ("EpbId", bp.get("EpbId")), ("CellCoordinate", bp["CellCoordinate"]),
|
||||||
|
("DecorationsInfo", _decor_i2s(bp.get("DecorationsInfo"))), ("Rotation", bp["Rotation"]),
|
||||||
|
("CompartmentHash", bp.get("CompartmentHash")), ("DefinitionHash", bp.get("DefinitionHash")))
|
||||||
|
|
||||||
|
|
||||||
|
def comp_s2i(bp):
|
||||||
|
return _od(("Id", bp["Id"]), ("EpbId", bp.get("EpbId")), ("CellCoordinate", bp["CellCoordinate"]),
|
||||||
|
("DecorationsInfo", _decor_s2i(bp.get("DecorationsInfo"))), ("Rotation", bp["Rotation"]),
|
||||||
|
("CompartmentHash", bp.get("CompartmentHash")), ("DefinitionHash", bp.get("DefinitionHash")))
|
||||||
|
|
||||||
|
|
||||||
|
def conn_i2s(bp):
|
||||||
|
"""one ConnectionBlueprintDto WS->storage: add EpbId:null, enums int->name (declaration order)."""
|
||||||
|
return _od(("Id", bp["Id"]), ("EpbId", bp.get("EpbId")), ("ActualDirection", bp["ActualDirection"]),
|
||||||
|
("GridCoordinate", bp["GridCoordinate"]), ("SlotType", SLOT[bp["SlotType"]]),
|
||||||
|
("State", STATE[bp["State"]]))
|
||||||
|
|
||||||
|
|
||||||
|
def conn_s2i(c):
|
||||||
|
"""inverse: drop EpbId, enums name->int (round-trip self-test only)."""
|
||||||
|
return _od(("Id", c["Id"]), ("ActualDirection", c["ActualDirection"]),
|
||||||
|
("GridCoordinate", c["GridCoordinate"]), ("SlotType", SLOT_I[c["SlotType"]]),
|
||||||
|
("State", STATE_I[c["State"]]))
|
||||||
|
|
||||||
|
|
||||||
|
def walkerdto_to_blueprint(walker, definitions=None, version=1):
|
||||||
|
"""ExpeditionDto.Trampler / WalkerDto -> WalkerBlueprintDto with recomputed hashes."""
|
||||||
|
chassis = comp_i2s(walker["Chassis"]["Blueprint"])
|
||||||
|
comps = [comp_i2s(c["Blueprint"]) for c in walker["Compartments"]]
|
||||||
|
conns = [conn_i2s(c["Blueprint"]) for c in walker["Connections"]]
|
||||||
|
bp = _od(("Id", walker.get("BlueprintId")), ("UniqueId", walker.get("BlueprintUniqueId")),
|
||||||
|
("Version", version), ("Chassis", chassis), ("Compartments", comps), ("Connections", conns),
|
||||||
|
("CompartmentsHash", compartments_hash(comps)), ("DefinitionsHash", ""),
|
||||||
|
("ConnectionsHash", connections_hash(conns)))
|
||||||
|
if definitions is not None:
|
||||||
|
bp["DefinitionsHash"] = definitions_hash(definitions)
|
||||||
|
return bp
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_roundtrip():
|
||||||
|
"""storage -> WS -> storage must be identity (and reproduce stored hashes) on the sample."""
|
||||||
|
p = os.path.join(os.path.dirname(__file__), "..", "extracted", "playfab_titledata_TramplerBlueprint3.json")
|
||||||
|
s = json.load(open(p))
|
||||||
|
comps_ws = [comp_s2i(c) for c in s["Compartments"]]
|
||||||
|
conns_ws = [conn_s2i(c) for c in s["Connections"]]
|
||||||
|
comps_back = [comp_i2s(c) for c in comps_ws]
|
||||||
|
conns_back = [conn_i2s(c) for c in conns_ws]
|
||||||
|
cj = lambda o: json.dumps(o, separators=(",", ":"))
|
||||||
|
assert cj(comps_back) == cj(s["Compartments"]), "compartment round-trip not identity"
|
||||||
|
assert cj(conns_back) == cj(s["Connections"]), "connection round-trip not identity"
|
||||||
|
assert compartments_hash(comps_back) == s["CompartmentsHash"], "CompartmentsHash mismatch"
|
||||||
|
assert connections_hash(conns_back) == s["ConnectionsHash"], "ConnectionsHash mismatch"
|
||||||
|
print("round-trip OK: storage->WS->storage is byte-identical; #1 & #3 hashes reproduce.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_verify_roundtrip()
|
||||||
|
exp = os.path.join(os.path.dirname(__file__), "..", "extracted", "master_GetExpedition.json")
|
||||||
|
if os.path.exists(exp):
|
||||||
|
tr = json.load(open(exp)).get("Trampler")
|
||||||
|
if tr:
|
||||||
|
bp = walkerdto_to_blueprint(tr)
|
||||||
|
out = os.path.join(os.path.dirname(__file__), "..", "extracted", "host_trampler_blueprint.json")
|
||||||
|
json.dump(bp, open(out, "w"), indent=1, ensure_ascii=False)
|
||||||
|
print("converted host trampler -> %s" % out)
|
||||||
|
print(" chassis=%s comps=%d conns=%d" % (bp["Chassis"]["EpbId"], len(bp["Compartments"]), len(bp["Connections"])))
|
||||||
|
print(" CompartmentsHash=%s" % bp["CompartmentsHash"])
|
||||||
|
print(" ConnectionsHash =%s" % bp["ConnectionsHash"])
|
||||||
|
print(" DefinitionsHash =%s (provisional - needs live verify)" % (bp["DefinitionsHash"] or "<empty>"))
|
||||||
270
reverse/ws_scrape.py
Normal file
270
reverse/ws_scrape.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Decode the SAND master-server WebSocket stream out of a packet capture.
|
||||||
|
|
||||||
|
The master server is a .NET `ClientWebSocket` to `ws://<region>.hologryph.com/gameclient/`
|
||||||
|
(port 80, **cleartext** — confirmed from the IL2CPP metadata: only `ws://` literals, no `wss`).
|
||||||
|
Messages are request/response `OperationResult<T>` plus server-push `IClientEvent`s, serialized
|
||||||
|
by the game's `IDataSerializer` (JsonDataSerializer / BsonDataSerializer — JSON is the likely
|
||||||
|
default; this decoder tries JSON, BSON and MessagePack so the exact encoding doesn't block us).
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
1. groups packets into TCP streams, reassembles each direction by sequence number
|
||||||
|
2. finds the WebSocket HTTP upgrade (`GET /gameclient/ ... Upgrade: websocket` / `101`)
|
||||||
|
3. parses RFC-6455 frames (handles masking + continuation), per direction
|
||||||
|
4. decodes each message payload (JSON -> BSON -> MessagePack -> hex) and prints it
|
||||||
|
5. tags messages whose shape matches a known DTO (ServerDto, RegionInfo,
|
||||||
|
CompartmentDefinitionDto, ResearchNode*, OperationResult, IClientEvent...)
|
||||||
|
|
||||||
|
Because it's cleartext, NO MITM/cert is needed for this channel — just capture port 80.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
venv/bin/python reverse/ws_scrape.py <capture.pcapng> [--port 80] [--host hologryph]
|
||||||
|
[--out extracted/master_ws.json]
|
||||||
|
"""
|
||||||
|
import sys, json, struct, argparse
|
||||||
|
from collections import defaultdict
|
||||||
|
from scapy.all import rdpcap, IP, IPv6, TCP, Raw
|
||||||
|
|
||||||
|
try:
|
||||||
|
import msgpack
|
||||||
|
except Exception:
|
||||||
|
msgpack = None
|
||||||
|
|
||||||
|
# ---- known DTO field-sets, to label decoded objects (from il2cpp/dump.cs) ----
|
||||||
|
DTO_SIGNATURES = {
|
||||||
|
"ServerDto": {"Name", "Description", "UpTime", "Ip", "Port"},
|
||||||
|
"WorldEndpointData": {"worldName", "address", "port", "custom"},
|
||||||
|
"CompartmentDefinitionDto": {"EpbId", "HP", "Weight", "Properties", "CrownPrice"},
|
||||||
|
"ResearchNodeJsonDto": {"Id", "Tier", "ResearchPrice", "RequiredNodesIds", "DependentNodesIds"},
|
||||||
|
"ResearchTreeJsonDto": {"Roots", "Nodes"},
|
||||||
|
"ItemDto": {"DefinitionName", "SellPrice", "Outfitable", "IsLarge"},
|
||||||
|
"ShopItemDto": {"DefinitionName", "BuyPrice", "Amount"},
|
||||||
|
"PriceDto": {"ItemDefinition", "Amount"},
|
||||||
|
"UserDto": {"Id", "DatabaseId"},
|
||||||
|
"OperationResult": {"IsSucceed", "Error", "Status"},
|
||||||
|
"LoginProcessResult": {"IsSuccess", "Error"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def label_obj(o, depth=0):
|
||||||
|
"""Best-effort DTO name for a decoded dict (and recurse a little)."""
|
||||||
|
names = []
|
||||||
|
if isinstance(o, dict):
|
||||||
|
keys = set(o.keys())
|
||||||
|
for name, sig in DTO_SIGNATURES.items():
|
||||||
|
if sig <= keys:
|
||||||
|
names.append(name)
|
||||||
|
if depth < 2:
|
||||||
|
for v in o.values():
|
||||||
|
names += label_obj(v, depth + 1)
|
||||||
|
elif isinstance(o, list) and o and depth < 2:
|
||||||
|
names += label_obj(o[0], depth + 1)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- minimal BSON decoder (Newtonsoft.Json.Bson wire format) ----------
|
||||||
|
def _bson_cstring(b, i):
|
||||||
|
j = b.index(0, i)
|
||||||
|
return b[i:j].decode("utf-8", "replace"), j + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _bson_doc(b, i):
|
||||||
|
ln = struct.unpack_from("<i", b, i)[0]
|
||||||
|
end = i + ln
|
||||||
|
i += 4
|
||||||
|
out = {}
|
||||||
|
while i < end - 1:
|
||||||
|
t = b[i]; i += 1
|
||||||
|
name, i = _bson_cstring(b, i)
|
||||||
|
if t == 0x01: # double
|
||||||
|
out[name] = struct.unpack_from("<d", b, i)[0]; i += 8
|
||||||
|
elif t == 0x02: # string
|
||||||
|
sl = struct.unpack_from("<i", b, i)[0]; i += 4
|
||||||
|
out[name] = b[i:i + sl - 1].decode("utf-8", "replace"); i += sl
|
||||||
|
elif t == 0x03: # embedded doc
|
||||||
|
out[name], i = _bson_doc(b, i)
|
||||||
|
elif t == 0x04: # array
|
||||||
|
d, i = _bson_doc(b, i)
|
||||||
|
out[name] = [d[k] for k in sorted(d, key=lambda x: int(x) if x.isdigit() else 0)]
|
||||||
|
elif t == 0x08: # bool
|
||||||
|
out[name] = b[i] != 0; i += 1
|
||||||
|
elif t == 0x0A: # null
|
||||||
|
out[name] = None
|
||||||
|
elif t == 0x10: # int32
|
||||||
|
out[name] = struct.unpack_from("<i", b, i)[0]; i += 4
|
||||||
|
elif t == 0x12: # int64
|
||||||
|
out[name] = struct.unpack_from("<q", b, i)[0]; i += 8
|
||||||
|
else:
|
||||||
|
raise ValueError("bson type 0x%02x" % t)
|
||||||
|
return out, end
|
||||||
|
|
||||||
|
|
||||||
|
def try_decode(payload):
|
||||||
|
"""Return (encoding, obj) or (None, None)."""
|
||||||
|
if not payload:
|
||||||
|
return None, None
|
||||||
|
# JSON (text frames / utf8)
|
||||||
|
s = payload.lstrip()
|
||||||
|
if s[:1] in (b"{", b"[", b'"') or s[:1].isdigit():
|
||||||
|
try:
|
||||||
|
return "json", json.loads(payload.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# BSON (starts with int32 length == len(payload))
|
||||||
|
if len(payload) >= 5:
|
||||||
|
try:
|
||||||
|
ln = struct.unpack_from("<i", payload, 0)[0]
|
||||||
|
if ln == len(payload):
|
||||||
|
obj, _ = _bson_doc(payload, 0)
|
||||||
|
return "bson", obj
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# MessagePack
|
||||||
|
if msgpack is not None:
|
||||||
|
try:
|
||||||
|
return "msgpack", msgpack.unpackb(payload, raw=False, strict_map_key=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------- TCP reassembly + WS framing ----------------------
|
||||||
|
def reassemble(packets, port, host_filter):
|
||||||
|
"""Yield (stream_key, client_bytes, server_bytes) for TCP streams on `port`
|
||||||
|
whose handshake Host matches host_filter (substring, case-insensitive)."""
|
||||||
|
flows = defaultdict(lambda: {"a": {}, "b": {}, "ips": None})
|
||||||
|
for p in packets:
|
||||||
|
if not p.haslayer(TCP):
|
||||||
|
continue
|
||||||
|
ipl = p[IP] if p.haslayer(IP) else (p[IPv6] if p.haslayer(IPv6) else None)
|
||||||
|
if ipl is None:
|
||||||
|
continue
|
||||||
|
t = p[TCP]
|
||||||
|
if t.dport != port and t.sport != port:
|
||||||
|
continue
|
||||||
|
a = (ipl.src, t.sport)
|
||||||
|
b = (ipl.dst, t.dport)
|
||||||
|
key = tuple(sorted([a, b]))
|
||||||
|
f = flows[key]
|
||||||
|
f["ips"] = key
|
||||||
|
side = "a" if (a == key[0]) else "b"
|
||||||
|
if p.haslayer(Raw):
|
||||||
|
f[side][t.seq] = bytes(p[Raw].load)
|
||||||
|
|
||||||
|
for key, f in flows.items():
|
||||||
|
# the side whose endpoint port == `port` is the server
|
||||||
|
(e0, e1) = key
|
||||||
|
server_side = "a" if e0[1] == port else "b"
|
||||||
|
client_side = "b" if server_side == "a" else "a"
|
||||||
|
cbytes = b"".join(f[client_side][s] for s in sorted(f[client_side]))
|
||||||
|
sbytes = b"".join(f[server_side][s] for s in sorted(f[server_side]))
|
||||||
|
# require a WS handshake, optionally host-filtered
|
||||||
|
if b"Upgrade: websocket" not in cbytes and b"upgrade: websocket" not in cbytes:
|
||||||
|
continue
|
||||||
|
if host_filter:
|
||||||
|
hdr = cbytes[:cbytes.find(b"\r\n\r\n") + 4].lower()
|
||||||
|
if host_filter.lower().encode() not in hdr:
|
||||||
|
continue
|
||||||
|
yield key, cbytes, sbytes
|
||||||
|
|
||||||
|
|
||||||
|
def ws_frames(stream):
|
||||||
|
"""Parse RFC-6455 frames from a byte stream that starts AFTER the HTTP handshake.
|
||||||
|
Yields (opcode, payload_bytes). Reassembles continuation frames."""
|
||||||
|
i = stream.find(b"\r\n\r\n")
|
||||||
|
i = i + 4 if i >= 0 else 0
|
||||||
|
b = stream
|
||||||
|
n = len(b)
|
||||||
|
cur_op = None
|
||||||
|
cur = bytearray()
|
||||||
|
while i + 2 <= n:
|
||||||
|
b0 = b[i]; b1 = b[i + 1]
|
||||||
|
fin = b0 & 0x80
|
||||||
|
op = b0 & 0x0F
|
||||||
|
masked = b1 & 0x80
|
||||||
|
ln = b1 & 0x7F
|
||||||
|
i += 2
|
||||||
|
if ln == 126:
|
||||||
|
if i + 2 > n:
|
||||||
|
break
|
||||||
|
ln = struct.unpack_from(">H", b, i)[0]; i += 2
|
||||||
|
elif ln == 127:
|
||||||
|
if i + 8 > n:
|
||||||
|
break
|
||||||
|
ln = struct.unpack_from(">Q", b, i)[0]; i += 8
|
||||||
|
mask = b""
|
||||||
|
if masked:
|
||||||
|
if i + 4 > n:
|
||||||
|
break
|
||||||
|
mask = b[i:i + 4]; i += 4
|
||||||
|
if i + ln > n:
|
||||||
|
break
|
||||||
|
data = bytearray(b[i:i + ln]); i += ln
|
||||||
|
if masked:
|
||||||
|
for k in range(len(data)):
|
||||||
|
data[k] ^= mask[k & 3]
|
||||||
|
if op == 0x8: # close
|
||||||
|
break
|
||||||
|
if op in (0x9, 0xA): # ping/pong
|
||||||
|
continue
|
||||||
|
if op == 0x0: # continuation
|
||||||
|
cur += data
|
||||||
|
else:
|
||||||
|
if cur_op is not None:
|
||||||
|
pass
|
||||||
|
cur_op = op
|
||||||
|
cur = bytearray(data)
|
||||||
|
if fin and cur_op in (0x1, 0x2):
|
||||||
|
yield cur_op, bytes(cur)
|
||||||
|
cur_op = None
|
||||||
|
cur = bytearray()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("pcap")
|
||||||
|
ap.add_argument("--port", type=int, default=80)
|
||||||
|
ap.add_argument("--host", default="hologryph", help="handshake Host substring filter ('' = any)")
|
||||||
|
ap.add_argument("--out", default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
pk = rdpcap(args.pcap)
|
||||||
|
messages = []
|
||||||
|
nstreams = 0
|
||||||
|
for key, cbytes, sbytes in reassemble(pk, args.port, args.host):
|
||||||
|
nstreams += 1
|
||||||
|
# show the handshake request line + host
|
||||||
|
line0 = cbytes.split(b"\r\n", 1)[0].decode(errors="replace")
|
||||||
|
host = ""
|
||||||
|
for ln in cbytes[:cbytes.find(b"\r\n\r\n") + 4].split(b"\r\n"):
|
||||||
|
if ln.lower().startswith(b"host:"):
|
||||||
|
host = ln.decode(errors="replace")
|
||||||
|
print("### WS stream %s %s %s" % (key, line0, host))
|
||||||
|
for direction, stream in (("S->C", sbytes), ("C->S", cbytes)):
|
||||||
|
for op, payload in ws_frames(stream):
|
||||||
|
enc, obj = try_decode(payload)
|
||||||
|
rec = {"stream": str(key), "dir": direction,
|
||||||
|
"opcode": ("text" if op == 1 else "binary"),
|
||||||
|
"encoding": enc, "len": len(payload)}
|
||||||
|
if obj is not None:
|
||||||
|
rec["dto"] = sorted(set(label_obj(obj)))
|
||||||
|
rec["data"] = obj
|
||||||
|
else:
|
||||||
|
rec["raw_prefix"] = payload[:48].hex()
|
||||||
|
messages.append(rec)
|
||||||
|
tag = ("[" + ",".join(rec.get("dto", [])) + "] ") if rec.get("dto") else ""
|
||||||
|
preview = json.dumps(obj, default=str)[:160] if obj is not None else rec["raw_prefix"]
|
||||||
|
print(" %s %-7s %-8s %s%s" % (direction, rec["opcode"], enc, tag, preview))
|
||||||
|
|
||||||
|
print("\n%d WS stream(s), %d message(s)" % (nstreams, len(messages)))
|
||||||
|
if nstreams == 0:
|
||||||
|
print("No WebSocket streams found. If the backend was online, check --port/--host, "
|
||||||
|
"or the capture may not span the master-server connection.")
|
||||||
|
if args.out:
|
||||||
|
json.dump({"_source": args.pcap, "messages": messages}, open(args.out, "w"),
|
||||||
|
indent=1, default=str, ensure_ascii=False)
|
||||||
|
print("wrote", args.out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user