Files
SandTools/docs/TRAMPLER.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

9.5 KiB
Raw Blame History

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 C1C8 matched in-game). Same as the Id field; arrays preserve it.

WalkerDtoWalkerBlueprintDto 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.jsoncompartments[].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 C1C8 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).
  • 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).