Files
SandTools/CLAUDE.md
DownloadPizza bd01d6753a trampler: hash lifecycle RE'd via live round-trip (client recomputes all 5 on save)
- All 5 walker hashes are recomputed client-side on save from the local
  CompartmentsDatabase + CompartmentDefinitionDto (same build => byte-identical
  hashes; no per-walker secret). Wiping any/all hashes loads/opens/edits fine;
  one in-editor save regenerates everything (+ new file UUID & UniqueId).
- VERSION flag (WalkerBlueprintContainer.ValidateVersion) depends only on the
  structural Compartments/Connections hashes, not the Definition hashes; ERRORS
  is a separate structural check. Hashes = integrity/version-staleness markers,
  not security. Server upsert validation still untested.
- Document the 5-hash table (3 top-level + 2 per-part) and offline-computability
  in docs/TRAMPLER.md + CLAUDE.md; include experiment artifacts + baseline.
2026-06-16 09:48:50 +02:00

12 KiB
Raw Blame History

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)) YESwalker/walker_hashes.py
ConnectionsHash top-level: MD5(JSON(Connections list)) YESwalker/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 031) · 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 CraftingRecipeBundles.
  • 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.pythe 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).