- .gitignore: ignore /extracted/ (regenerable game data) and /tmp/ (scratch) - git rm --cached the 38 extracted/ files: untracked but left on disk, not deleted - master_scrape.py: add GetExpedition=7 to ACTIONS (was missing; pulls ExpeditionDto.Trampler) - docs: mark master-server /connect blocker cleared 2026-06-16 (server back up); server-side upsert hash validation remains untested (live re-test not yet run)
12 KiB
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 .wbtwalker 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>(hasUnityPy,bson/pymongo,scapy,Pillow,websockets). - Symlinks (git-ignored, machine-specific — repoint if the machine changes):
bundles/→ gameStreamingAssets/aa/StandaloneWindows64/(35 bundles, ~6.8 GB)Walkers/→…/LocalLow/Hologryph/SAND/Data/Walkers/(live.wbtsaves)
- 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 (theWalkers/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 viawalker/recover_key.py. pymongo'sbson.encodereproduces 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 (the master-server /connect blocker is cleared as of 2026-06-16 — server back
up — but the live re-test has not been run yet).
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, mergeEpbId→{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— reproduceCompartmentsHash/ConnectionsHashoffline (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 decodeSerializedBytesblobs.odin_read.py <file> [out].extract_data.py— generic MonoBehaviour extractor via typetrees → JSON inextracted/.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 (viaLandmarkBehaviour) →extracted/island_names.json.extract_i2.py— I2 Localization English term table (manual parse) → i2 terms JSON.workbench_bundles.py— workbench EntityBlueprint → referencedCraftingRecipeBundles.discord_recipes.py— emit Discord monospace recipe tables (workbench + production lines).component_census.py— tally ECS$typecomponents 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. Seedocs/MASTER_SERVER.mdfor the fullClientActionenum /OperationResult<T>envelope.playfab_scrape.py— PlayFab REST (read-only), runs outside the game. Required--title-id; auth via--steam-ticketor--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-serverWalkerDto(e.g.GetExpedition.Trampler) → loadableWalkerBlueprintDto+ 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 fromdump.cs, xref finder, body disasm + float-constant extraction.resolve_decomp.py— annotateghidra/decomp.cwith 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—.wbtformat 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).